Email

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:

RecordNameValuePurpose
TXT (SPF)mail.example.comv=spf1 include:mailgun.org ~allMailgun's servers may send for the domain. One SPF record per name: merge with an existing one.
TXT (DKIM)<selector>._domainkey.mail.example.comthe public key Mailgun generates (k=rsa; p=...)Every email is signed; receivers check the signature against this key.
TXT (DMARC)_dmarc.mail.example.comv=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.commailgun.org (or the EU host Mailgun names)Open and click tracking; optional.
MXmail.example.comMailgun's MX hostsOnly 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

  1. Add the model record to packages/email/Colden.Email.Templates/Models/EmailModels.cs and the component to Templates/, built from EmailLayout and EmailButton, with the parameters Brand and Model.
  2. Add its strings to both .resx files of Resources/.
  3. Register the template id, component, model and subject in RazorEmailTemplateRenderer and the id in EmailTemplates.
  4. 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:

  1. Make the project a Razor project (<Project Sdk="Microsoft.NET.Sdk.Razor">) that references Colden.Email.Templates.

  2. Write the model record and the component with the parameters Brand (EmailBrand) and Model, inside EmailLayout (with Preheader, Title, FooterNote and the content) and with EmailButton for the action; Styles has the kit's inline styles.

  3. 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);
    
  4. 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

KeyDefaultMeaning
Email:ProviderSmtpSmtp (Mailpit, a relay) or Mailgun.
Email:From, Email:FromName, Email:ReplyToThe sender, on the verified domain.
Email:UnsubscribeAddressemptyThe mailto: of List-Unsubscribe in notifications.
Email:Mailgun:Domain, Email:Mailgun:RegionEUThe sending domain and Mailgun's region.
Email__Mailgun__ApiKey, Email__Mailgun__WebhookSigningKeysecretsThe API key and the webhook signing key.
Email:RetainBodiesDays7How long addresses and bodies are kept for resend and support.