Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/EmailService.cs
T
spouliot b8057295ec Redirect emails to dev address in non-production; fix PAID stamp color
- EmailService: add RedirectIfNonProd() mirroring SmsService pattern;
  reads SendGrid:DevRedirectEmail and redirects all outbound email in
  non-production so real customers are never contacted on local/dev
- appsettings.json: set DevRedirectEmail to spouliot@scppowdercoating.com
- PdfService: revert Opacity() (not in QuestPDF 2024.12.3); use
  Colors.Green.Lighten2 for stamp + border to achieve lighter look

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:32:01 -04:00

179 lines
7.3 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace PowderCoating.Infrastructure.Services;
public class EmailService : IEmailService
{
private readonly IConfiguration _configuration;
private readonly ILogger<EmailService> _logger;
private readonly IHostEnvironment _hostEnvironment;
public EmailService(IConfiguration configuration, ILogger<EmailService> logger, IHostEnvironment hostEnvironment)
{
_configuration = configuration;
_logger = logger;
_hostEnvironment = hostEnvironment;
}
/// <summary>
/// Sends an email via SendGrid. In non-production environments the subject is prefixed with
/// "[ENV]" (e.g. "[Development]", "[Staging]") so recipients immediately know the email came
/// from a test environment and don't act on it as if it were real.
/// </summary>
public async Task<(bool Success, string? ErrorMessage)> SendEmailAsync(
string toEmail,
string toName,
string subject,
string plainTextBody,
string? htmlBody = null,
byte[]? attachmentData = null,
string? attachmentFilename = null,
string? attachmentContentType = null,
string? replyToEmail = null,
string? replyToName = null)
{
var apiKey = _configuration["SendGrid:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-") || apiKey == "SG.placeholder")
{
_logger.LogWarning("SendGrid API key is not configured. Email to {ToEmail} skipped.", toEmail);
return (false, "SendGrid not configured");
}
if (!_hostEnvironment.IsProduction())
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
(toEmail, toName) = RedirectIfNonProd(toEmail, toName);
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
try
{
var client = new SendGridClient(apiKey);
var msg = MailHelper.CreateSingleEmail(
new EmailAddress(fromEmail, fromName),
new EmailAddress(toEmail, toName),
subject, plainTextBody, htmlBody ?? plainTextBody);
if (!string.IsNullOrWhiteSpace(replyToEmail))
msg.SetReplyTo(new EmailAddress(replyToEmail, replyToName));
if (attachmentData != null && attachmentData.Length > 0 && !string.IsNullOrWhiteSpace(attachmentFilename))
{
await msg.AddAttachmentAsync(
attachmentFilename,
new MemoryStream(attachmentData),
attachmentContentType ?? "application/octet-stream");
}
return await DispatchAsync(client, msg, toEmail, subject);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception sending email to {ToEmail}", toEmail);
return (false, ex.Message);
}
}
/// <summary>
/// Sends an email with one or more file attachments via SendGrid. Intended for scenarios
/// such as attaching job photos to a ready-for-pickup notification. Callers are responsible
/// for keeping total attachment size under SendGrid's 30 MB per-message limit; this method
/// skips any attachment whose Data array is empty.
/// </summary>
public async Task<(bool Success, string? ErrorMessage)> SendEmailWithAttachmentsAsync(
string toEmail,
string toName,
string subject,
string plainTextBody,
string? htmlBody = null,
IList<EmailAttachment>? attachments = null,
string? replyToEmail = null,
string? replyToName = null)
{
var apiKey = _configuration["SendGrid:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-") || apiKey == "SG.placeholder")
{
_logger.LogWarning("SendGrid API key is not configured. Email to {ToEmail} skipped.", toEmail);
return (false, "SendGrid not configured");
}
if (!_hostEnvironment.IsProduction())
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
(toEmail, toName) = RedirectIfNonProd(toEmail, toName);
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
try
{
var client = new SendGridClient(apiKey);
var msg = MailHelper.CreateSingleEmail(
new EmailAddress(fromEmail, fromName),
new EmailAddress(toEmail, toName),
subject, plainTextBody, htmlBody ?? plainTextBody);
if (!string.IsNullOrWhiteSpace(replyToEmail))
msg.SetReplyTo(new EmailAddress(replyToEmail, replyToName));
if (attachments != null)
{
foreach (var attachment in attachments.Where(a => a.Data.Length > 0))
{
await msg.AddAttachmentAsync(
attachment.Filename,
new MemoryStream(attachment.Data),
attachment.ContentType);
}
}
return await DispatchAsync(client, msg, toEmail, subject);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception sending email with attachments to {ToEmail}", toEmail);
return (false, ex.Message);
}
}
/// <summary>
/// In non-production environments, redirects outbound email to <c>SendGrid:DevRedirectEmail</c>
/// so real customers are never contacted outside of production. Double-gated on environment
/// name AND the config value so a misconfigured prod deploy can't accidentally redirect.
/// </summary>
private (string email, string name) RedirectIfNonProd(string toEmail, string toName)
{
if (_hostEnvironment.IsProduction()) return (toEmail, toName);
var devEmail = _configuration["SendGrid:DevRedirectEmail"];
if (string.IsNullOrWhiteSpace(devEmail)) return (toEmail, toName);
_logger.LogWarning("Non-production environment: redirecting email from {Original} to dev address {Dev}", toEmail, devEmail);
return (devEmail, $"[DEV → {toName} <{toEmail}>]");
}
/// <summary>
/// Sends the built SendGrid message and interprets the HTTP response. Extracted so both
/// send methods share identical dispatch and logging logic.
/// </summary>
private async Task<(bool Success, string? ErrorMessage)> DispatchAsync(
SendGridClient client, SendGridMessage msg, string toEmail, string subject)
{
var response = await client.SendEmailAsync(msg);
if ((int)response.StatusCode >= 200 && (int)response.StatusCode < 300)
{
_logger.LogInformation("Email sent to {ToEmail}: {Subject}", toEmail, subject);
return (true, null);
}
var body = await response.Body.ReadAsStringAsync();
_logger.LogWarning("SendGrid returned {StatusCode} for {ToEmail}: {Body}", response.StatusCode, toEmail, body);
return (false, $"HTTP {(int)response.StatusCode}");
}
}