- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes. - Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging. - Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR. - Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes. - Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots. - Introduce XiboFeatureManifests for hardcoded feature ACLs per role. - Add docker-compose.dev.yml for local development with PostgreSQL setup.
174 lines
6.7 KiB
C#
174 lines
6.7 KiB
C#
using Microsoft.Extensions.Options;
|
|
using SendGrid;
|
|
using SendGrid.Helpers.Mail;
|
|
|
|
namespace OTSSignsOrchestrator.Server.Services;
|
|
|
|
public class EmailOptions
|
|
{
|
|
public const string Section = "Email";
|
|
public string SendGridApiKey { get; set; } = string.Empty;
|
|
public string SenderEmail { get; set; } = "noreply@otssigns.com";
|
|
public string SenderName { get; set; } = "OTS Signs";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Email service backed by SendGrid. All methods catch exceptions, log as Error,
|
|
/// and return bool success — they never throw.
|
|
/// </summary>
|
|
public class EmailService
|
|
{
|
|
private readonly ILogger<EmailService> _logger;
|
|
private readonly EmailOptions _options;
|
|
private readonly SendGridClient? _client;
|
|
|
|
public EmailService(ILogger<EmailService> logger, IOptions<EmailOptions> options)
|
|
{
|
|
_logger = logger;
|
|
_options = options.Value;
|
|
|
|
if (!string.IsNullOrWhiteSpace(_options.SendGridApiKey))
|
|
{
|
|
_client = new SendGridClient(_options.SendGridApiKey);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("SendGrid API key not configured — emails will be logged only");
|
|
}
|
|
}
|
|
|
|
public async Task<bool> SendWelcomeEmailAsync(
|
|
string toEmail, string firstName, string instanceUrl, string invitationLink)
|
|
{
|
|
var subject = "Welcome to OTS Signs — Your CMS is Ready!";
|
|
var html = $"""
|
|
<h2>Welcome to OTS Signs, {HtmlEncode(firstName)}!</h2>
|
|
<p>Your Xibo CMS instance has been provisioned and is ready to use.</p>
|
|
<p><strong>Instance URL:</strong> <a href="{HtmlEncode(instanceUrl)}">{HtmlEncode(instanceUrl)}</a></p>
|
|
<p><strong>Accept your invitation to get started:</strong></p>
|
|
<p><a href="{HtmlEncode(invitationLink)}" style="display:inline-block;padding:12px 24px;background-color:#3B82F6;color:#fff;text-decoration:none;border-radius:6px;">Accept Invitation</a></p>
|
|
<p>If you have any questions, please contact our support team.</p>
|
|
<p>— The OTS Signs Team</p>
|
|
""";
|
|
|
|
return await SendEmailAsync(toEmail, subject, html);
|
|
}
|
|
|
|
public async Task<bool> SendPaymentFailedEmailAsync(
|
|
string toEmail, string companyName, int failedCount)
|
|
{
|
|
var daysSinceFirst = failedCount * 3; // approximate
|
|
string subject;
|
|
string urgency;
|
|
|
|
if (daysSinceFirst >= 21)
|
|
{
|
|
subject = $"FINAL NOTICE — Payment Required for {companyName}";
|
|
urgency = "<p style=\"color:#DC2626;font-weight:bold;\">⚠️ FINAL NOTICE: Your service will be suspended if payment is not received immediately.</p>";
|
|
}
|
|
else if (daysSinceFirst >= 7)
|
|
{
|
|
subject = $"URGENT — Payment Failed for {companyName}";
|
|
urgency = "<p style=\"color:#F59E0B;font-weight:bold;\">⚠️ URGENT: Please update your payment method to avoid service interruption.</p>";
|
|
}
|
|
else
|
|
{
|
|
subject = $"Payment Failed for {companyName}";
|
|
urgency = "<p>Please update your payment method at your earliest convenience.</p>";
|
|
}
|
|
|
|
var html = $"""
|
|
<h2>Payment Failed — {HtmlEncode(companyName)}</h2>
|
|
{urgency}
|
|
<p>We were unable to process your payment (attempt #{failedCount}).</p>
|
|
<p>Please update your payment method to keep your OTS Signs service active.</p>
|
|
<p>— The OTS Signs Team</p>
|
|
""";
|
|
|
|
return await SendEmailAsync(toEmail, subject, html);
|
|
}
|
|
|
|
public async Task<bool> SendTrialEndingEmailAsync(
|
|
string toEmail, string firstName, DateTime trialEndDate)
|
|
{
|
|
var daysLeft = (int)Math.Ceiling((trialEndDate - DateTime.UtcNow).TotalDays);
|
|
var subject = $"Your OTS Signs Trial Ends in {Math.Max(0, daysLeft)} Day{(daysLeft != 1 ? "s" : "")}";
|
|
var html = $"""
|
|
<h2>Hi {HtmlEncode(firstName)},</h2>
|
|
<p>Your OTS Signs trial ends on <strong>{trialEndDate:MMMM dd, yyyy}</strong>.</p>
|
|
<p>To continue using your CMS without interruption, please subscribe before your trial expires.</p>
|
|
<p>— The OTS Signs Team</p>
|
|
""";
|
|
|
|
return await SendEmailAsync(toEmail, subject, html);
|
|
}
|
|
|
|
public async Task<bool> SendReportEmailAsync(
|
|
string toEmail, string attachmentName, byte[] attachment, string mimeType)
|
|
{
|
|
var subject = $"OTS Signs Report — {attachmentName}";
|
|
var html = $"""
|
|
<h2>OTS Signs Report</h2>
|
|
<p>Please find the attached report: <strong>{HtmlEncode(attachmentName)}</strong></p>
|
|
<p>This report was generated automatically by the OTS Signs Orchestrator.</p>
|
|
<p>— The OTS Signs Team</p>
|
|
""";
|
|
|
|
return await SendEmailAsync(toEmail, subject, html, attachmentName, attachment, mimeType);
|
|
}
|
|
|
|
private async Task<bool> SendEmailAsync(
|
|
string toEmail,
|
|
string subject,
|
|
string htmlContent,
|
|
string? attachmentName = null,
|
|
byte[]? attachmentData = null,
|
|
string? attachmentMimeType = null)
|
|
{
|
|
try
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.LogWarning(
|
|
"SendGrid not configured — would send email to {Email}: {Subject}",
|
|
toEmail, subject);
|
|
return true; // Not a failure — just not configured
|
|
}
|
|
|
|
var from = new EmailAddress(_options.SenderEmail, _options.SenderName);
|
|
var to = new EmailAddress(toEmail);
|
|
var msg = MailHelper.CreateSingleEmail(from, to, subject, null, htmlContent);
|
|
|
|
if (attachmentData is not null && attachmentName is not null)
|
|
{
|
|
msg.AddAttachment(
|
|
attachmentName,
|
|
Convert.ToBase64String(attachmentData),
|
|
attachmentMimeType ?? "application/octet-stream");
|
|
}
|
|
|
|
var response = await _client.SendEmailAsync(msg);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogInformation("Email sent to {Email}: {Subject}", toEmail, subject);
|
|
return true;
|
|
}
|
|
|
|
var body = await response.Body.ReadAsStringAsync();
|
|
_logger.LogError(
|
|
"SendGrid returned {StatusCode} for email to {Email}: {Body}",
|
|
response.StatusCode, toEmail, body);
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to send email to {Email}: {Subject}", toEmail, subject);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string HtmlEncode(string value) =>
|
|
System.Net.WebUtility.HtmlEncode(value);
|
|
}
|