Нотифікації

Email нотифікації

Вивчаємо email як надійний зовнішній канал для важливих нотифікацій. Реалізуємо відправку через MailKit, HTML-шаблони, абстракцію IEmailSender та чергу на базі Channel<T>.

Email нотифікації

Push-нотифікації та SSE відмінно працюють коли браузер онлайн і дав дозвіл на сповіщення. Але що з користувачами, які відхилили push-дозвіл? Або нотифікація занадто важлива, щоб покластися лише на браузер?

Email — перевірений часом надійний канал. Він не залежить від того, чи відкритий браузер, чи увімкнені push-сповіщення. Email зберігається у поштовій скриньці і чекає, доки користувач його прочитає.

Email нотифікації доречні для:

  • Підтвердження важливих дій (реєстрація, зміна пароля, замовлення)
  • Нагадувань після тривалої відсутності
  • Дайджестів (щотижневий підсумок активності)
  • Критичних сповіщень безпеки (вхід з нового пристрою)
Що ми побудуємо: розширимо NotificationsDemo — додамо MailKit для SMTP-відправки, HTML-шаблони листів, абстракцію IEmailSender і Channel<T>-чергу для асинхронної відправки у фоні.

Архітектура Email-системи

HTTP-запит або BackgroundService
        |
        | Enqueue(EmailTask)
        ↓
  Channel<EmailTask>    ← thead-safe черга
        |
        | DequeueAsync()
        ↓
  EmailSenderService    ← BackgroundService
        |
        | IEmailSender.SendAsync()
        ↓
  SmtpEmailSender
        |
        | SMTP (TLS)
        ↓
   Mailtrap / Gmail / SendGrid

Ключовий принцип: HTTP-обробник лише ставить завдання в чергу і одразу відповідає. Реальна відправка відбувається у фоновому сервісі — повільність SMTP не сповільнює HTTP.


Крок 1: Встановлення MailKit

dotnet add package MailKit

MailKit — сучасна, повнофunkціональна бібліотека для роботи з Email у .NET. Підтримує SMTP, IMAP, POP3 і є рекомендованою заміною застарілого SmtpClient зі стандартної бібліотеки.


Крок 2: Конфігурація

appsettings.json
{
  "Email": {
    "SmtpHost": "smtp.mailtrap.io",
    "SmtpPort": 587,
    "Username": "your_mailtrap_username",
    "Password": "your_mailtrap_password",
    "FromAddress": "noreply@notificationsdemo.com",
    "FromName": "NotificationsDemo"
  }
}

Для розробки використовуємо Mailtrap — безкоштовний сервіс, який перехоплює всі листи і відображає їх у веб-інтерфейсі. Реальні отримувачі листів не отримують.


Крок 3: Абстракція IEmailSender

Визначаємо інтерфейс до реалізації. Це дозволяє легко підмінити SMTP на SendGrid, Mailgun або мок у тестах:

Services/IEmailSender.cs
namespace NotificationsDemo.Services;

// Контракт — незалежний від конкретного поштового провайдера
public interface IEmailSender
{
    Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default);
}

// Структура Email-повідомлення
public record EmailMessage(
    string To,          // Адреса отримувача
    string ToName,      // Ім'я отримувача (для персоналізації)
    string Subject,     // Тема листа
    string HtmlBody,    // HTML-контент (допускає форматування)
    string? PlainText = null  // Текстовий варіант (для email-клієнтів без HTML)
);

Крок 4: SMTP-реалізація через MailKit

Services/SmtpEmailSender.cs
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using NotificationsDemo.Services;

namespace NotificationsDemo.Services;

public class SmtpEmailSender : IEmailSender
{
    private readonly IConfiguration _config;
    private readonly ILogger<SmtpEmailSender> _logger;

    public SmtpEmailSender(IConfiguration config, ILogger<SmtpEmailSender> logger)
    {
        _config = config;
        _logger = logger;
    }

    public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default)
    {
        // MimeMessage — MailKit-представлення Email-повідомлення
        var email = new MimeMessage();

        email.From.Add(new MailboxAddress(
            _config["Email:FromName"],    // "NotificationsDemo"
            _config["Email:FromAddress"] // "noreply@notificationsdemo.com"
        ));

        email.To.Add(new MailboxAddress(message.ToName, message.To));
        email.Subject = message.Subject;

        // BodyBuilder допомагає будувати multipart повідомлення (HTML + текст)
        var builder = new BodyBuilder
        {
            HtmlBody = message.HtmlBody,
            TextBody = message.PlainText ?? StripHtml(message.HtmlBody) // Текст як fallback
        };

        email.Body = builder.ToMessageBody();

        // SmtpClient з MailKit (не плутати з System.Net.Mail.SmtpClient!)
        using var client = new SmtpClient();

        // Connect: SecureSocketOptions.StartTls відповідає STARTTLS (порт 587)
        await client.ConnectAsync(
            _config["Email:SmtpHost"],
            int.Parse(_config["Email:SmtpPort"]!),
            SecureSocketOptions.StartTls,
            cancellationToken);

        // Автентифікація на SMTP-сервері
        await client.AuthenticateAsync(
            _config["Email:Username"],
            _config["Email:Password"],
            cancellationToken);

        await client.SendAsync(email, cancellationToken);
        await client.DisconnectAsync(quit: true, cancellationToken);

        _logger.LogInformation("Email надіслано до {To}: {Subject}", message.To, message.Subject);
    }

    // Простий стриппер HTML для текстового fallback
    private static string StripHtml(string html)
        => System.Text.RegularExpressions.Regex.Replace(html, "<[^>]*>", "").Trim();
}

Крок 5: HTML-шаблон листа

Замість жорстко закодованого HTML виносимо шаблони в окремий сервіс:

Services/EmailTemplateService.cs
namespace NotificationsDemo.Services;

public class EmailTemplateService
{
    // Шаблон нотифікаційного листа
    public string BuildNotificationEmail(string userName, string notificationType, string message, string? actionUrl)
    {
        var actionButton = actionUrl is not null
            ? $"<a href='{actionUrl}' style='background:#3b82f6;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;display:inline-block;margin-top:16px;'>Переглянути</a>"
            : string.Empty;

        // String interpolation для простих шаблонів
        // У реальних проєктах — Razor template (AddRazorPages + IViewRenderService)
        return $"""
            <!DOCTYPE html>
            <html lang="uk">
            <head><meta charset="UTF-8"><title>Нове сповіщення</title></head>
            <body style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px;background:#f8fafc;">
                <div style="background:white;border-radius:12px;padding:32px;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
                    <h1 style="color:#0f172a;font-size:24px;margin-bottom:8px;">🔔 Нове сповіщення</h1>
                    <p style="color:#475569;">Привіт, <strong>{userName}</strong>!</p>
                    <div style="background:#f1f5f9;border-left:4px solid #3b82f6;padding:16px;border-radius:0 8px 8px 0;margin:16px 0;">
                        <span style="font-size:12px;color:#64748b;text-transform:uppercase;">{notificationType}</span>
                        <p style="color:#0f172a;margin:4px 0 0;">{message}</p>
                    </div>
                    {actionButton}
                    <hr style="border:none;border-top:1px solid #e2e8f0;margin:24px 0;">
                    <p style="color:#94a3b8;font-size:12px;">
                        Це автоматичне сповіщення від NotificationsDemo.<br>
                        Якщо ви не хочете отримувати такі листи — змініть налаштування у профілі.
                    </p>
                </div>
            </body>
            </html>
            """;
    }

    // Шаблон дайджесту — кілька нотифікацій в одному листі
    public string BuildDigestEmail(string userName, IEnumerable<(string Type, string Message)> notifications)
    {
        var items = string.Join("", notifications.Select(n =>
            $"<li style='margin-bottom:8px;'><strong>{n.Type}</strong>: {n.Message}</li>"));

        return $"""
            <!DOCTYPE html><html lang="uk"><head><meta charset="UTF-8"></head>
            <body style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px;">
                <h1>📬 Ваш дайджест</h1>
                <p>Привіт, <strong>{userName}</strong>! Ось що сталося поки вас не було:</p>
                <ul style="line-height:1.8;">{items}</ul>
            </body></html>
            """;
    }
}

Крок 6: Черга та фоновий сервіс відправки

Повторно використовуємо IBackgroundTaskQueue зі статті 06. Лише ставимо Email-задачу в чергу:

Endpoints/NotificationEndpoints.cs
// Ендпоінт, що одночасно зберігає нотифікацію і ставить Email у чергу
private static async Task<IResult> CreateNotificationWithEmail(
    AppDbContext db,
    NotificationBroadcaster broadcaster,
    IBackgroundTaskQueue taskQueue,
    CreateNotificationRequest request)
{
    var notification = new Notification
    {
        UserId = request.UserId,
        Type = request.Type,
        Message = request.Message,
        ActionUrl = request.ActionUrl,
        IsRead = false,
        CreatedAt = DateTime.UtcNow
    };

    db.Notifications.Add(notification);
    await db.SaveChangesAsync();

    var response = new NotificationResponse(
        notification.Id, notification.Type, notification.Message,
        notification.ActionUrl, notification.IsRead, notification.CreatedAt);

    // Real-time: надсилаємо через SSE активним клієнтам
    broadcaster.Broadcast(notification.UserId, response);

    // Async Email: ставимо в чергу — не блокуємо HTTP-відповідь
    taskQueue.Enqueue(async (services, ct) =>
    {
        var emailSender = services.GetRequiredService<IEmailSender>();
        var templates = services.GetRequiredService<EmailTemplateService>();

        // У реальному проєкті — отримуємо email із БД або User сервісу
        var userEmail = $"user{request.UserId}@example.com"; // Демо
        var htmlBody = templates.BuildNotificationEmail(
            userName: $"Користувач {request.UserId}",
            notificationType: request.Type,
            message: request.Message,
            actionUrl: request.ActionUrl);

        await emailSender.SendAsync(new EmailMessage(
            To: userEmail,
            ToName: $"Користувач {request.UserId}",
            Subject: $"🔔 Нове сповіщення: {request.Type}",
            HtmlBody: htmlBody
        ), ct);
    });

    return Results.Created($"/notifications/{notification.Id}", response);
}

Крок 7: Реєстрація

Program.cs
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<EmailTemplateService>();

AddTransient для SmtpEmailSender — кожен раз отримуємо новий екземпляр, що гарантує чисте SMTP-з'єднання.


Захист від спаму (концептуально)

Якщо ваш Email-сервер надсилає багато листів, потрібно налаштувати DNS-записи:

ЗаписПризначенняПриклад
SPFДозволені IP для надсиланняv=spf1 include:sendgrid.net ~all
DKIMЦифровий підпис листівПублічний ключ у TXT-записі
DMARCПолітика при невдалій перевірціv=DMARC1; p=reject;

Без цих записів ваші листи потраплятимуть у спам. Налаштування залежить від DNS-провайдера та поштового сервісу.


Підсумок

Email нотифікації замикають коло каналів доставки:

  • IEmailSender — абстракція, що дозволяє підмінити провайдер без зміни бізнес-логіки
  • MailKit — сучасна SMTP-бібліотека з підтримкою TLS та OAuth
  • HTML-шаблони — персоналізовані листи з чітким дизайном
  • Channel<T>-черга — HTTP не блокується, Email відправляється у фоні
  • Mailtrap — тестуємо без ризику надіслати реальні листи

У фінальній статті ми зводимо всі дев'ять вивчених підходів у єдину порівняльну таблицю та decision tree для вибору правильного інструменту.

Copyright © 2026