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 overloadsSendAsync(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.IEmailTemplateRenderer—string Render(string templateContent, IDictionary<string, object?> data, string? layoutContent = null). Só o contrato; a engine de substituição é do consumidor.IEmailTemplateRepository—Task<string?> GetTemplateAsync(string templateKey, int? tenantId = null, CancellationToken ct = default). Diferença vs WG: removido o acoplamento aoENotificationDeliveryMethod(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
SmtpEmailServiceembrulhaSystem.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.
MailFactorysync-over-async (como o WG): descartado — o WG inicializa o sender no construtor viaGetAwaiter().GetResult(). O consumidor seleciona o sender por provider switch no DI lendoEmailOptions.Provider, sem factory bloqueante (decisão D5 da Fase 1).EmailOptionsno template: descartado — options tipadas de cross-cutting vivem no DevPackAppSettings/por coerência (CacheOptions,ServiceBusOptions); o template só faz o bind.
Consequências
- Adição não-quebrante. Nenhum tipo existente muda;
EmailAttachmentpreexistente é reusado. - O consumidor implementa
IEmailService,IEmailTemplateRenderer,IEmailTemplateRepositorye seleciona o sender porEmailOptions.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
- ADR-001 — No framework wrappers (o DevPack ship contratos, não embrulha o framework de entrega).
Notifications/Email/IEmailService.csNotifications/Email/IEmailTemplateRenderer.csNotifications/Email/IEmailTemplateRepository.csDtos/EmailResult.csAppSettings/EmailOptions.cs