Mailafhandeling en templates
Dit hoofdstuk beschrijft de database-intentie voor applicatiegestuurde e-mail vanuit OefenHub. Identity-providerinterne mailflows zoals wachtwoordreset, provider-e-mailverificatie en MFA-mail vallen buiten deze database-informatie.
De mailmodule ondersteunt de eerste concrete flow: externe OefenHub-uitnodigingsmails voor relatie-uitnodigingen naar e-mailadressen die nog niet bij een intern account horen. Verzoeken worden niet synchron via SMTP verstuurd in de gebruikersrequest, maar als TickerQ-deliverytaak gepland zodat retry en achtergrondverwerking centraal gebeuren.
10.1 Eigenaarschap
| Onderdeel | Keuze |
|---|---|
| Project | OefenHub.Mail |
| DbContext | MailDbContext |
| Schema | mail |
| Primaire verantwoordelijkheid | Mailtemplates, templatehistory, mailaanvragen/verzendpogingen en technische mailstatus. |
OefenHub.Mail is eigenaar van externe applicatiemail. OefenHub.Communication blijft eigenaar van interne systeemberichten, mailboxitems, privéthreads en badges.
10.2 Tabelsamenvatting
| Tabelnaam | Categorie | Doel / verantwoordelijkheid | Gerelateerde tabellen |
|---|---|---|---|
EmailTemplates | Beheerbare mailtemplatebron met codegedreven referentie en veilige HTML-inhoud. | EmailTemplateHistory, MailSendAttempts | |
EmailTemplateHistory | Historie van templatewijzigingen door beheerders. | EmailTemplates, soft link naar Users | |
MailSendAttempts | Herleidbare registratie van mailaanvragen/verzendpogingen, TickerQ-planning, technische foutstatus, bronworkflow en correlation zonder secrets. | EmailTemplates, MailSendAttemptEvents, soft links naar veroorzakende domeinobjecten | |
MailSendAttemptEvents | Append-only deliveryhistorie per logisch mailbericht met veilige gebruikerscategorieën en technische beheerinformatie zonder secrets. | MailSendAttempts |
Exacte kolommen worden bij implementatie in EF-configuratie en migratie vastgelegd, maar onderstaande velden zijn de minimale datacontractuele intentie.
10.3 EmailTemplates
| Veldnaam | Type | Default | PK | FK | Verwijst naar | Unique | Nullable | Index | Opmerking |
|---|---|---|---|---|---|---|---|---|---|
| Id | uniqueidentifier | newsequentialid/newid | J | N | - | J | N | J | Technische sleutel. |
| CodeReference | nvarchar(100) | - | N | N | Codegedreven templatecatalogus | J | N | J | Unieke technische referentie, bijvoorbeeld relationship-invitation. |
| Name | nvarchar(200) | - | N | N | - | N | N | N | Beheerbare naam voor de beheer-UI. |
| SubjectTemplate | nvarchar(400) | - | N | N | - | N | N | N | Onderwerp met toegestane placeholders. |
| BodyHtmlTemplate | nvarchar(max) | - | N | N | - | N | N | N | Veilige HTML-body met toegestane placeholders. |
| IsActive | bit | 1 | N | N | - | N | N | J | Alleen actieve templates mogen runtime gebruikt worden. |
| CreatedAtUtc | datetime2 | sysutcdatetime | N | N | - | N | N | N | Aanmaakmoment. |
| UpdatedAtUtc | datetime2 | - | N | N | - | N | J | N | Laatste wijziging. |
10.4 EmailTemplateHistory
| Veldnaam | Type | Default | PK | FK | Verwijst naar | Unique | Nullable | Index | Opmerking |
|---|---|---|---|---|---|---|---|---|---|
| Id | uniqueidentifier | newsequentialid/newid | J | N | - | J | N | J | Technische sleutel. |
| EmailTemplateId | uniqueidentifier | - | N | J | mail.EmailTemplates.Id | N | N | J | Harde FK binnen mailmodule. |
| ChangedAtUtc | datetime2 | sysutcdatetime | N | N | - | N | N | J | Wijzigingsmoment. |
| ChangedByUserId | uniqueidentifier | - | N | N | identity.Users.Id | N | J | J | Soft link naar beheerder; geen cross-schema harde FK. |
| CodeReference | nvarchar(100) | - | N | N | Codegedreven templatecatalogus | N | N | J | Snapshot van de templatecode. |
| Name | nvarchar(200) | - | N | N | - | N | N | N | Snapshot van de beheernaam. |
| SubjectTemplate | nvarchar(400) | - | N | N | - | N | N | N | Snapshot van het onderwerp. |
| BodyHtmlTemplate | nvarchar(max) | - | N | N | - | N | N | N | Snapshot van de HTML-body. |
| ChangeReason | nvarchar(500) | - | N | N | - | N | N | N | Korte samenvatting. |
10.5 MailSendAttempts
| Veldnaam | Type | Default | PK | FK | Verwijst naar | Unique | Nullable | Index | Opmerking |
|---|---|---|---|---|---|---|---|---|---|
| Id | uniqueidentifier | newsequentialid/newid | J | N | - | J | N | J | Technische sleutel. |
| TemplateCodeReference | nvarchar(100) | - | N | N | Codegedreven templatecatalogus | N | N | J | Snapshot van templatecode voor query/debug. |
| AddressKey | nvarchar(50) | - | N | N | Mail-configuratiecontext | N | N | J | Bijvoorbeeld Invites; bepaalt runtime de afzenderconfiguratie. Geen afzenderadres- of wachtwoordsnapshot. |
| ToEmail | nvarchar(320) | - | N | N | - | N | N | J | Ontvanger zoals gebruikt voor verzending. |
| Subject | nvarchar(400) | - | N | N | - | N | N | N | Gerenderd onderwerp op aanvraagmoment. |
| BodyHtml | nvarchar(max) | - | N | N | - | N | N | N | Gerenderde HTML-body op aanvraagmoment. |
| Status | nvarchar(30) | Queued | N | N | Gesloten statusset | N | N | J | Queued, Sending, Sent, Failed, Canceled. Canceled betekent dat de mailaanvraag niet veilig kon worden gekoppeld aan een deliveryjob en daarom niet verzonden mag worden. |
| CorrelationId | nvarchar(100) | - | N | N | technische correlatie | N | N | J | Koppelt de oorspronkelijke gebruikersactie/workflow aan de TickerQ-delivery en technische logging. Meerdere mailpogingen uit dezelfde uitnodigingsactie kunnen zo gezamenlijk worden teruggevonden. |
| SourceEntityType | nvarchar(100) | - | N | N | bronworkflow | N | J | J | Generieke bronkoppeling, bijvoorbeeld RelationshipInvitation. |
| SourceEntityId | nvarchar(100) | - | N | N | bronworkflow-id | N | J | J | Bronentity-id als string, bijvoorbeeld RelationshipInvitation.Id. |
| Purpose | nvarchar(100) | - | N | N | maildoel | N | J | J | Doel binnen bronworkflow, bijvoorbeeld InitialInvitation of Reminder. |
Een reminder voor een relatie-uitnodiging maakt altijd een nieuwe MailSendAttempts-rij met Purpose = Reminder; het oorspronkelijke initial-invitation-mailattempt wordt niet hergebruikt. De bronkoppeling blijft SourceEntityType = RelationshipInvitation en SourceEntityId = RelationshipInvitations.Id, zodat de detailsweergave alle initiële en herinneringsmails veilig kan samenvatten.
| RequestedByUserId | uniqueidentifier | - | N | N | identity.Users.Id | N | J | J | Soft link naar uitnodigende gebruiker. |
| QueuedAtUtc | datetimeoffset | sysutcdatetime | N | N | - | N | N | N | Aanvraagmoment. |
| ScheduledForUtc | datetimeoffset | sysutcdatetime | N | N | TickerQ planning | N | N | J | Earliest execution time voor de deliveryjob. |
| LastAttemptAtUtc | datetimeoffset | - | N | N | - | N | J | N | Laatste verzendpoging. |
| SentAtUtc | datetimeoffset | - | N | N | - | N | J | N | Succesvol verzonden moment. |
| AttemptCount | int | 0 | N | N | - | N | N | N | Aantal SMTP-pogingen door de deliveryservice. |
| LastError | nvarchar(max) | - | N | N | - | N | J | N | Veilige fouttype-informatie, geen credentials of providerpayloads. |
10.5A MailSendAttemptEvents
TECH-REL-MAIL-001 voegt een append-only eventtabel toe naast MailSendAttempts. MailSendAttempts blijft de actuele samenvatting van één logisch mailbericht; MailSendAttemptEvents bevat de tijdlijn van queue-, delivery-, retry- en annuleringsgebeurtenissen. Relatieverzoekdetails gebruiken hiervoor een mail-readmodelcontract en lezen niet rechtstreeks mailtabellen uit.
| Veldnaam | Type | Default | PK | FK | Verwijst naar | Unique | Nullable | Index | Opmerking |
|---|---|---|---|---|---|---|---|---|---|
| Id | uniqueidentifier | newsequentialid/newid | J | N | - | J | N | J | Technische sleutel. |
| MailSendAttemptId | uniqueidentifier | - | N | J | mail.MailSendAttempts.Id | N | N | J | Logisch mailbericht waarvoor dit event geldt. |
| AttemptNumber | int | - | N | N | - | N | N | J | SMTP-/delivery-uitvoeringspoging binnen dezelfde mailattempt. Geen max-retrywaarde. |
| EventType | nvarchar(50) | - | N | N | Gesloten eventset | N | N | J | Bijvoorbeeld Queued, SendStarted, SendSucceeded, SendFailed, Canceled, SourceContextInvalid. |
| OccurredAtUtc | datetimeoffset | sysutcdatetime | N | N | - | N | N | J | Tijdstip van het event. |
| TechnicalErrorCode | nvarchar(100) | - | N | N | Technische foutcategorie | N | J | J | Stabiele foutcode voor logging/beheer, zonder secrets. |
| TechnicalErrorType | nvarchar(100) | - | N | N | Technische foutcategorie | N | J | N | Alleen voor beheer/diagnose, niet voor gewone gebruikers. |
| UserMessageCode | nvarchar(100) | - | N | N | Veilige gebruikerscategorie | N | N | J | Wordt door readmodels vertaald naar veilige melding zoals E-mail aangeboden aan de mailserver.. |
Zichtbaarheid voor gewone gebruikers wordt niet als IsUserVisible in de tabel opgeslagen. Het mail-readmodel bepaalt welke eventtypes en UserMessageCode-waarden veilig getoond mogen worden.
10.6 Business rules
CodeReferenceis uniek en codegedreven. Beheer mag de technische referentie niet vrij toevoegen of verwijderen.- SMTP-adressen en wachtwoorden komen uit configuratie/user secrets en worden nooit in
mail.*opgeslagen.AddressKeyis de enige persistente verwijzing naar de gekozen afzenderconfiguratie; delivery gebruikt altijd de actuele configuratie achter die sleutel. - SMTP-transport gebruikt
MailKitzodat zowel implicit SSL/SMTPS (Mail:EnableSsl=true,Mail:EnableStartTls=false, typisch poort 465) als expliciete STARTTLS (Mail:EnableSsl=false,Mail:EnableStartTls=true, typisch poort 587) veilig configureerbaar zijn.Mail:EnableSslenMail:EnableStartTlszijn wederzijds exclusief en worden bij applicatiestart gevalideerd; een ongeldige combinatie is een configuratiefout die de applicatie bewust niet laat doorstarten. Mail:SmtpTimeoutSecondsbegrenst de connect/auth/send-stap. Een timeout wordt als tijdelijke delivery-fout geregistreerd en door TickerQ-retrybeleid opnieuw geprobeerd.- Templatehistory bevat geen SMTP-wachtwoorden, tokens of secrets.
- Uitnodigingsmails naar onbekende relatieontvangers delen de volledige naam van de uitnodiger alleen na expliciete bevestiging in de UI.
- Wanneer de gebruiker externe uitnodiging annuleert, wordt geen relatie-uitnodiging voor onbekende adressen opgeslagen.
- Een mailverzoek is geen autorisatiebewijs. Links uit e-mail leiden naar reguliere routes die opnieuw server-side autorisatie en uitnodigingsstatus controleren.
- Retry of herverzending moet idempotent zijn en mag geen dubbele relatie-uitnodigingen veroorzaken.
- Wanneer een mailattempt een bronkoppeling heeft, controleert de deliveryjob vóór SMTP-submit of de broncontext nog geldig is. Voor
RelationshipInvitationbetekent dit: alleen verzenden zolang het verzoek nogPendingis. - Maildelivery loopt via TickerQ. Eén uitnodigingsmail wordt zo snel mogelijk gepland; meerdere mails worden met 30 seconden interval gepland om SMTP-bursts te voorkomen.
- Een mailqueue-aanvraag is pas succesvol wanneer zowel het
MailSendAttempts-record is opgeslagen als de bijbehorende TickerQ-deliveryjob is gepland. De aanroepende domeinflow moet synchrone mailvoorwaarden zoals template, senderconfiguratie en placeholderrendering vooraf valideren. Wanneer jobplanning daarna alsnog niet veilig lukt, wordt de mailattempt geannuleerd en mag de aanroepende domeinflow geen misleidende successtatus tonen. CorrelationIdblijft over de request-, relatie-uitnodiging-, mailqueue- en deliveryketen gelijk of herleidbaar, zodat support en logging later kunnen reconstrueren welke actie welke mailpogingen veroorzaakte.
10.7 Relatie met relatiebeheer
Voor een bestaand intern account wordt de relatie-uitnodiging via relationships.RelationshipInvitations en een intern communication.SystemMessages-record afgehandeld.
Voor een onbekend e-mailadres ontstaat pas na expliciete externe-mailbevestiging een zichtbaar RelationshipInvitations-record met ToUserId = null. Vóór deze opslag valideert de webflow de synchrone mailvoorwaarden via OefenHub.Mail: actieve template, senderconfiguratie en placeholderrendering. Daarna wordt de uitnodiging aangemaakt en wordt de mail als MailSendAttempts vastgelegd en via TickerQ gepland. Als de queue of jobplanning daarna alsnog faalt, wordt het zojuist aangemaakte externe relatieverzoek ingetrokken/verborgen en toont de gebruikersflow geen misleidende succesmelding.
Aanvulling - Reminderkanaal na claimen
mail.MailSendAttempts blijft alleen het externe mailkanaal vastleggen. Wanneer een externe relatie-uitnodiging later aan een bestaande OefenHub-gebruiker wordt gekoppeld, worden nieuwe herinneringen niet opnieuw als mailattempt aangemaakt maar als intern systeembericht. Alleen uitnodigingen zonder gekoppelde ToUserId gebruiken nog een nieuwe mailattempt met Purpose = Reminder.
Deze regel voorkomt dat OefenHub externe e-mail blijft sturen naar een gebruiker die inmiddels via het interne berichtensysteem bereikbaar is.