Pular para o conteúdo principal
Versão: main (dev) 🚧

ADR-009 — Email abstractions in the DevPack, concrete delivery in the consumer

Data: 2026-06-29 Status: Aceito Contexto: DevPack 10.8.0 Pareado com: Fase 1 da iniciativa Email/Cache/Templates — concretos no template ITLab.Template.Next (Infrastructure/Email/). Base de referência: contrato IEmailService + DTOs + renderer/repository de template portados de ITLab.WorkGuardian (CrossCutting/Email/*, Notifications/Templating/*).

Contexto

A iniciativa pede um serviço de email para o Template. O WorkGuardian (WG) já tem o padrão maduro: um IEmailService com overloads de SendAsync + SendFromTemplateAsync, DTOs EmailResult/EmailAttachment, um renderer de template HTML (${key}/${{key}} + flattening + layout) e um repositório de templates com fallback hierárquico. A questão é onde mora cada peça — DevPack (universal) ou template (canon).

O DevPack já tinha o precedente análogo no Cache: ship o contrato (ICacheService, CacheOptions, CachedKey) e nenhuma impl — o consumidor provê MemoryCacheService/DistributedCacheService. Email é o mesmo formato.

Decisão

Os contratos de email + as options tipadas vivem no DevPack; os concretos (senders, renderer, repositório, layouts) vivem no consumidor.

1. Contratos (Notifications/Email/)

  • IEmailService — três overloads SendAsync (destinatário único, múltiplos, múltiplos + attachments) + SendFromTemplateAsync(templateKey, IDictionary<string, object> data, subject, string[] destination, int? tenantId = null, IEnumerable<EmailAttachment>? attachments = null, CancellationToken). Portado verbatim do WG.
  • IEmailTemplateRendererstring Render(string templateContent, IDictionary<string, object?> data, string? layoutContent = null). Só o contrato; a engine de substituição é do consumidor.
  • IEmailTemplateRepositoryTask<string?> GetTemplateAsync(string templateKey, int? tenantId = null, CancellationToken ct = default). Diferença vs WG: removido o acoplamento ao ENotificationDeliveryMethod (que pertence ao pipeline multi-canal Teams/Slack). O repositório de Email é HTML-only.

2. DTOs (Dtos/)

  • EmailResult (record (bool Success, string? MessageId = null, string? ErrorMessage = null)) — novo.
  • EmailAttachment (record (Stream Content, string FileName, string? ContentType = null)) — já existia no DevPack; reusado.

3. Options (AppSettings/EmailOptions)

IAppOptions, seção EmailOptions, props private init (coerente com CacheOptions/ServiceBusOptions). Provider (Smtp | SendGrid) é o seletor que o consumidor usa para escolher a impl no DI. Nested SmtpOptions + SendGridOptions. Config via appsettings (não global-settings no banco, como o WG faz) — decisão D4 da Fase 1.

4. Tipo de data: IDictionary<> no contrato, tolerante a null no renderer

IEmailService.SendFromTemplateAsync recebe IDictionary<string, object> (placeholders externos sempre têm valor — espelha o WG, que usava Dictionary<string, object>; promovido a IDictionary<> para não vazar o tipo concreto na API pública). IEmailTemplateRenderer.Render recebe IDictionary<string, object?> porque a impl do consumidor enriquece o dicionário com valores derivados (subject, year, ...) que podem ser nulos. Os dois são coerentes: o serviço repassa um dicionário non-null-valued ao renderer, que aceita o superset null-tolerante.

Alternativas consideradas

  • Shipar os concretos (SMTP/SendGrid/renderer/repo) no DevPack: descartado — viola ADR-001 ("no framework wrappers"). Um SmtpEmailService embrulha System.Net.Mail; o renderer e o repositório carregam decisões de branding/layout e de resolução de arquivos que são do canon. Mesmo critério do Cache (DevPack ship o contrato, não a impl).
  • Repositório acoplado a um enum de canal (como o WG): descartado — o enum de delivery-method pertence ao pipeline multi-canal (Teams/Slack), fora do escopo. Email é HTML-only.
  • MailFactory sync-over-async (como o WG): descartado — o WG inicializa o sender no construtor via GetAwaiter().GetResult(). O consumidor seleciona o sender por provider switch no DI lendo EmailOptions.Provider, sem factory bloqueante (decisão D5 da Fase 1).
  • EmailOptions no template: descartado — options tipadas de cross-cutting vivem no DevPack AppSettings/ por coerência (CacheOptions, ServiceBusOptions); o template só faz o bind.

Consequências

  • Adição não-quebrante. Nenhum tipo existente muda; EmailAttachment preexistente é reusado.
  • O consumidor implementa IEmailService, IEmailTemplateRenderer, IEmailTemplateRepository e seleciona o sender por EmailOptions.Provider.
  • Templates HTML (layouts + Auth/*) e a resolução de arquivos com fallback tenant→default→base são do consumidor — o DevPack não opina sobre branding.
  • Primeiro consumidor: template ITLab.Template.Next (Fase 1 — Infrastructure/Email/).

Referências