How the kit keeps transactional email deliverable: the Mailgun domain's SPF, DKIM and DMARC records, the provider's webhooks, the suppression list, List-Unsubscribe for notifications, Mailpit in development, and adding a template.
The kit sends transactional email: sign-in codes, invitations, receipts, failed payments, the end of a trial. It renders every email from a template when the request makes it, queues it in the same transaction, and the worker hands it to Mailgun (or to an SMTP relay, Mailpit locally). What keeps those emails out of the spam folder is the sending domain's DNS records, honoring bounces and complaints, and sending notifications with an unsubscribe header. The packages are described in packages/email.
The sending domain
Send from a subdomain of the product's domain (mail.example.com), so its reputation stays apart from the people's own mail on example.com. Add the domain in Mailgun, in the region of Email:Mailgun:Region (EU or US), and set Email:From to an address on it.
DNS records
Mailgun shows the exact values when the domain is added; they have this shape:
| Record | Name | Value | Purpose |
|---|---|---|---|
| TXT (SPF) | mail.example.com | v=spf1 include:mailgun.org ~all | Mailgun's servers may send for the domain. One SPF record per name: merge with an existing one. |
| TXT (DKIM) | <selector>._domainkey.mail.example.com | the public key Mailgun generates (k=rsa; p=...) | Every email is signed; receivers check the signature against this key. |
| TXT (DMARC) | _dmarc.mail.example.com | v=DMARC1; p=none; rua=mailto:[email protected] | What receivers do with mail that fails SPF and DKIM, and where they send their reports. |
| CNAME (tracking) | email.mail.example.com | mailgun.org (or the EU host Mailgun names) | Open and click tracking; optional. |
| MX | mail.example.com | Mailgun's MX hosts | Only to receive mail on the domain (an unsubscribe mailbox, for example). |
Start DMARC at p=none and read the reports for a few weeks; once every legitimate source passes, move to p=quarantine, then p=reject. Mailgun's domain page verifies the records; send only once it shows them all verified.
The provider's webhooks
In Mailgun, point the webhooks delivered, permanent_fail, temporary_fail, complained and unsubscribed at https://<api host>/webhooks/mailgun, and set the webhook signing key as Email__Mailgun__WebhookSigningKey (a secret). The api checks every event's signature and refuses an old or replayed one. A delivered event marks the delivery Delivered; a hard bounce, a complaint or an unsubscription marks it and puts the address on the suppression list.
Suppression
The suppression list (email_suppressions) holds the SHA-256 hash of each address the kit sends nothing to, with the reason (bounced, complained, unsubscribed, operator), where it came from (mailgun, operator) and since when. The worker checks it before every send and marks a suppressed delivery Suppressed without calling the provider. Code adds or removes an address through IEmailSuppressions, in the caller's unit of work.
The list keeps hashes, so super-admins act on one address at a time:
GET /v1/admin/email/suppressions/[email protected] # suppressed? why, from where, since when
PUT /v1/admin/email/suppressions/[email protected] { "reason": "asked by mail" }
DELETE /v1/admin/email/suppressions/[email protected] # send to it again
Remove an address only when its owner asks for mail again or its mailbox works again: sending to an address that bounced or complained costs the domain's reputation. Each change is logged without the address.
Transactional email and notifications
Every template has a category. The kit's templates are transactional: they answer the person's own action or account, and carry no unsubscribe link (the identity server's codes, invitations, receipts, failed payments, the end of a trial). A product's template may be a notification instead (a digest, an announcement), which mailbox providers expect to carry List-Unsubscribe: the composer adds List-Unsubscribe: <mailto:{Email:UnsubscribeAddress}?subject=unsubscribe> when the address is set. Gmail and Yahoo also expect a one-click link (RFC 8058) from bulk senders: turn on unsubscribe tracking for the domain in Mailgun, which adds it, and its unsubscribed event suppresses the recipient.
Mailpit in development
The Compose stack sends through SMTP to Mailpit (Email__Smtp__Host=mailpit), which keeps every email and sends none. Open http://localhost:8025 to read them, with their headers, text part and a check of the HTML against the common clients. The api serves a template with sample data in Development: http://localhost:5080/dev/emails/welcome?culture=ro-RO (add &format=text for the text part). To try Mailgun, set Email__Provider=Mailgun and the Email__Mailgun__* variables in infra/compose/.env.
Adding a template
A template of the kit
- Add the model record to
packages/email/Colden.Email.Templates/Models/EmailModels.csand the component toTemplates/, built fromEmailLayoutandEmailButton, with the parametersBrandandModel. - Add its strings to both
.resxfiles ofResources/. - Register the template id, component, model and subject in
RazorEmailTemplateRendererand the id inEmailTemplates. - Add a sample to the preview and to
TemplateTests, run the tests twice (the first run writes the snapshots), review the snapshots and commit them.
A product's template
A product keeps its templates in its own assembly and registers them; it does not change the kit's package:
-
Make the project a Razor project (
<Project Sdk="Microsoft.NET.Sdk.Razor">) that referencesColden.Email.Templates. -
Write the model record and the component with the parameters
Brand(EmailBrand) andModel, insideEmailLayout(withPreheader,Title,FooterNoteand the content) and withEmailButtonfor the action;Styleshas the kit's inline styles. -
Register it with a template id of its own, the subject and the category:
builder.Services.AddColdenEmailTemplate<ProjectDigest, ProjectDigestEmail>( "project-digest", (model, brand) => $"{model.Projects} projects changed in {brand.ProductName}", EmailCategory.Notification); -
Send it as any email:
emailService.SendAsync(new EmailRequest("project-digest", model, recipient, culture, organizationId)).
A product's id that is already taken (by the kit or by another of its templates) stops the host when the renderer is built. packages/email/tests/Colden.Email.Tests/ProductTemplates follows these steps.
Configuration
| Key | Default | Meaning |
|---|---|---|
Email:Provider | Smtp | Smtp (Mailpit, a relay) or Mailgun. |
Email:From, Email:FromName, Email:ReplyTo | The sender, on the verified domain. | |
Email:UnsubscribeAddress | empty | The mailto: of List-Unsubscribe in notifications. |
Email:Mailgun:Domain, Email:Mailgun:Region | EU | The sending domain and Mailgun's region. |
Email__Mailgun__ApiKey, Email__Mailgun__WebhookSigningKey | secrets | The API key and the webhook signing key. |
Email:RetainBodiesDays | 7 | How long addresses and bodies are kept for resend and support. |