navree
Documentation

Mail delivery

Email APIs (SES, Mailgun, Postmark, Resend), transports per domain, workspace and organization, a delivery log fed by provider webhooks, suppressions, mail held for approval, open and click tracking, DKIM signing and sending-domain checks.

On this page

Introduction

Featuremail Mail covers rendering a message and handing it to a transport. A product that sends mail on behalf of its customers needs more than that. Each customer wants mail to come from their own domain, through their own provider. Every message has to be traceable after the fact. An address that bounces must not be mailed again. A send to the whole company may need someone to approve it first. navree-mail covers all of that:

PartWhat it does
Email API transportsAmazon SES, Mailgun, Postmark and Resend over HTTPS, each returning the provider's message id.
Named transportsSeveral configured transports, picked per message with mailer.via("campaigns").
Tenant transportsTransports stored per sending domain, workspace, organization or for the platform, encrypted, with a check before a customer saves one.
The delivery logOne row per message: who it went to (as a hash), through what, and what happened to it.
Provider eventsWebhook routes for SES, Mailgun, Postmark and Resend that record deliveries, bounces, complaints, opens and clicks, and emit them as events.
SuppressionsAddresses mail is not sent to. Hard bounces and complaints add them automatically.
Hold for approvalmail.hold(reason) parks a message in an outbox until an admin releases or discards it.
TrackingAn open image and signed click redirects, both logged against the delivery.
DKIM signingDKIM-Signature headers for mail sent over SMTP, with RSA or Ed25519 keys.
Sending domainsSPF, DKIM and DMARC checks for a domain, as a service and as mail:domains.

The API transports, named transports, DKIM signing and domain checks work with the plain MailModule. Everything that stores rows needs mail.delivery.enabled.

Email API transports

Four transports send through a provider's HTTP API instead of SMTP. Each one is a mail.transport like any other, so the configuration you know applies:

TransportShort formTable form
SES"ses://KEY_ID:SECRET@eu-west-1?configuration_set=events"{ type = "ses", region, access_key_id, secret_access_key, session_token, configuration_set }
Mailgun"mailgun://[email protected]?region=eu"{ type = "mailgun", domain, api_key, region }
Postmark"postmark://[email protected]?stream=outbound"{ type = "postmark", server_token, message_stream }
Resend"resend://[email protected]"{ type = "resend", api_key }

The short form fits in one environment variable:

.env

Percent-encode reserved characters in credentials: an SES secret's / is %2F and its + is %2B. An ses:// URL without credentials (ses://eu-west-1) reads AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN from the environment when the transport is built. Keys, secrets and tokens are Secrets: they never appear in Debug output or errors.

What each transport sends:

TransportRequestExtra headersMessage::metadataProvider id
SESPOST /v2/email/outbound-emails, signed with AWS Signature Version 4Headersmessage tags (characters other than letters, digits, _ and - become _)MessageId
MailgunPOST /v3/<domain>/messages, a form with api:<key>h:<name>v:<key> variablesid, without its angle brackets
PostmarkPOST /email with X-Postmark-Server-TokenHeadersMetadataMessageID
ResendPOST /emails with a bearer tokenheaderstagsid

SES sends the message to the configuration set when one is configured. That set's event destination (an SNS topic) is what reports deliveries and bounces back, as described under provider events. A refused request is MailError::Provider { transport, status, message }, with the provider's own message and never the request. Each transport also takes an endpoint (?endpoint= in the short form) to reach another base URL, such as a regional endpoint or a local mock server in tests.

Named transports

mail.transport is the default. Name more under mail.transports and pick one per message with via, for instance to send bulk campaigns through a separate SES configuration set so their bounces don't count against your account mail:

config/app.toml
Rust

via returns a ScopedMailer with send, deliver and queue. A queued message keeps its transport name: the mail.send job sends it through the same one. An unknown name is MailError::Config, and an invalid named transport fails the boot with its key in the message (`transports.campaigns`: ...).

What a send reports

send and queue return (), as before. deliver does what send does and returns a SendReport:

Rust

report.suppressed lists the recipients that were left out, and report.outbox_id names the outbox row of a held message.

Turning on the delivery pipeline

The log, provider events, suppressions, the outbox, tenant transports and tracking all store rows, so they need the database. Turn them on with mail.delivery.enabled:

src/app.rs
config/app.toml
KeyDefaultMeaning
mail.delivery.enabledfalseLog every message, leave out suppressed recipients, hold messages for approval, resolve tenant transports.
mail.delivery.suppresstrueLeave out recipients in mail_suppressions.
mail.delivery.auto_suppresstrueSuppress an address after a hard bounce or a complaint.
mail.delivery.tenantstrueLook up tenant transports in mail_transports.
mail.delivery.routestrueMount the admin routes under /api/mail. They need a route authorizer (AccessModule) and an authenticator, like every permission-checked route: set it to false in an app without them.

MailModule contributes the migration set mail whether the pipeline is on or not. It creates mail_transports, mail_deliveries, mail_events, mail_suppressions and mail_outbox. With enabled = true and no DatabaseModule, the boot fails. Stored transports and held messages are encrypted with the app key, so they also need CryptoModule and APP_KEY.

When the pipeline is off, none of this runs: the mailer renders, validates and hands the message to the transport, as described on the mail page.

Writes and transactions

The pipeline writes through the unit of work of the current task when there is an unfinished one. That is the request's unit of work while a handler runs. So the delivery row of a message sent from a handler commits with the request, and on SQLite the write doesn't wait behind the request's own lock. Mail sent from a queue worker, a command or an after_commit hook gets its own unit of work.

Sending for a tenant

MailScope says who a message is sent for: a workspace, an organization, or both. for_scope sends with it, so that tenant's transport, suppressions and log apply:

src/modules/announcements/service.rs
  • CurrentWorkspace::mail_scope() is the workspace and its organization. CurrentOrganization::mail_scope() is the organization alone, and MailScope::from(&workspace) works from a stored workspace in a job.
  • Build one by hand with MailScope::workspace(id), MailScope::organization(id) and .with_organization(id). MailScope::platform() is no tenant at all.
  • A queued message keeps its scope. Suppressions and tenant transports are checked when the job runs, so an address suppressed in the meantime is still left out.
  • for_scope(..).via("campaigns") combines both: the scope's suppressions and log, through a named transport.

Tenant transports

A tenant transport is a transport stored in mail_transports instead of configuration. TransportResolver looks one up for every message, most specific first:

TierApplies toStored with
domainMail whose sender is in the domain ([email protected]), sent for the workspace or organization that owns the domain, or for anyone when nobody doesTransportOwner::domain, workspace_domain, organization_domain
workspaceMail sent for the workspaceTransportOwner::workspace(id)
organizationMail sent for the organization or one of its workspacesTransportOwner::organization(id)
platformEvery message, ahead of the configurationTransportOwner::Platform
configEverything else: mail.transport, or the named transport via picksconfiguration

via skips the stored tiers. mail.delivery.tenants = false skips them everywhere.

Saving a tenant's transport

A settings page usually checks the settings with usable, then stores them with set:

src/modules/settings/mail.rs

The body is a transport's table form, such as {"type": "smtp", "url": "smtps://..."} or {"type": "mailgun", "domain": "mg.acme.test", "api_key": "..."}.

  • usable(&config) builds the transport and runs its check without sending anything. SMTP connects and authenticates; SES calls GetAccount; Mailgun reads the domain; Postmark reads the server; Resend lists the domains. A failure is MailError::Unusable with the reason, which the error mapping turns into a 422 with code mail_transport_unusable.
  • set(&uow, owner, &config) encrypts the settings with the app key and stores them, replacing the owner's transport if it has one, and enables it. check_stored(id) runs the check on a stored transport and records checked_at.
  • Tenant safety. A domain, workspace or organization transport may not set an endpoint, and an SMTP host on a private, loopback or other reserved network is refused under the HTTP client's SSRF policy. The platform tier may use an internal relay.
  • Reading and changing. get(&owner), list(tier), config(id) (the decrypted settings), set_enabled(&uow, id, false) and remove(&uow, &owner).

Built transports are cached until their row changes, so an SMTP connection pool is reused across messages.

The delivery log

With the pipeline on, every message gets a row in mail_deliveries before it goes to the transport, and the transport's answer updates it. Deliveries reads the log:

Rust

A Delivery has the workspace and organization, the status, the transport and its tier, the provider_id, the template, the subject, the sender, the number of recipients, the transport's error when it failed, and opened_at and clicked_at. Recipients are not stored as addresses. recipient_hash is the SHA-256 of the first recipient's address in lower case, so DeliveryFilter { recipient: Some("[email protected]".into()), .. } finds a person's mail without the log holding their address.

StatusMeaning
sendingHanded to the transport, no answer yet.
sentThe transport accepted it.
deliveredThe provider delivered it to the recipient's server.
bouncedIt bounced permanently.
complainedThe recipient marked it as spam.
failedThe transport refused it (error says why).
suppressedEvery recipient was suppressed: nothing was sent.

find(id), find_by_provider_id(id), list(filter, page) and events(id) read it. record(event) writes to it, as described next.

Provider events

Providers report what happened to a message by calling a webhook. MailModule mounts one route per provider whose secret is configured, under /api/mail/webhooks:

config/app.toml
RouteVerified by
POST /api/mail/webhooks/sesThe token query parameter, and the SNS topic when topic_arns lists any. Subscription confirmations are confirmed by fetching their SubscribeURL (only https://sns.<region>.amazonaws.com; confirm_subscriptions = false logs the URL instead).
POST /api/mail/webhooks/mailgunThe HMAC-SHA256 of timestamp and token with the signing key, at most 15 minutes old.
POST /api/mail/webhooks/postmarkThe token as the basic-auth password of the webhook URL, or a token query parameter.
POST /api/mail/webhooks/resendThe svix-signature HMAC of the message id, timestamp and body, at most 15 minutes old.

Each answers 204 once the events are recorded, and 401 with code invalid_signature when verification fails. The routes need the pipeline (mail.delivery.enabled). Put their secrets in .env: APP_MAIL__WEBHOOKS__MAILGUN__SIGNING_KEY=.... The SES route trusts the secret token in its URL and the topic; it does not check the SNS message signature, so keep the subscription URL private and list your topic in topic_arns.

Recording an event, with Deliveries::record:

  1. finds the delivery by its provider id and logs the event in mail_events;
  2. moves the delivery to delivered, bounced (hard bounces only) or complained, and stamps the first open and click (a click counts as an open);
  3. suppresses the recipient after a hard bounce or a complaint, in the delivery's workspace;
  4. emits an event once the unit of work commits.

An event for a message the log doesn't know is still logged and still suppresses.

EventNameFields besides delivery_id, workspace_id, provider, recipient, occurred_at
MailDeliveredmail.delivered
MailBouncedmail.bouncedpermanent, detail
MailComplainedmail.complained
MailOpenedmail.opened
MailClickedmail.clickedurl

Listen to them like any event, for instance to flag a contact whose address bounced:

src/modules/contacts/listeners.rs

Register it with m.listen::<MailBounced, FlagBouncedContact>().

Your own sources

ProviderEvent::from_ses, from_mailgun, from_postmark and from_resend parse a provider's payload, and ProviderEvent::new(provider, provider_id, kind) builds one by hand. Hand them to Deliveries::record from any route of your own, such as one behind your own signature check:

Rust

Suppressions

A suppressed address gets no mail. When every recipient of a message is suppressed, nothing is sent and the log has a suppressed row. When only some are, they are left out of to, cc and bcc, and report.suppressed names them.

A suppression applies to one workspace, or to every workspace when its workspace_id is None. Hard bounces and complaints suppress in the delivery's workspace, and in every workspace for mail sent without one. To manage the list yourself, use Suppressions:

Rust

add is idempotent: adding an address that is already suppressed in that workspace returns the existing row. remove(&uow, id), remove_email(&uow, workspace_id, email), find(id) and list(workspace_id, email, page) do the rest. Reasons are hard_bounce, complaint, unsubscribed and manual. Addresses are stored in lower case, without their display name.

Hold for approval

Some mail should not go out until someone has looked at it: a message to every employee, the first send of a new template, mail from a workspace on trial. hold puts a mailable in the outbox instead of sending it:

Rust

Message::hold(reason) does the same for a built message. queue holds at once rather than queueing. The rendered message is stored encrypted in mail_outbox with its scope, its named transport and its template. An admin then decides through Outbox, or through the admin routes:

  • list(status, workspace_id, page) and find(id) show what is waiting; message(id) decrypts the message for a preview.
  • release(id, by) sends it now, through its scope and transport, and marks it released. It returns the SendReport. If sending fails, it stays held.
  • discard(id, by) marks it discarded; it is never sent.

Releasing or discarding anything but a held message is MailError::Conflict (409), so two admins can't both release one. by is the admin's user id, stored in decided_by.

Holding mail needs the pipeline. Without it, send returns MailError::Missing.

Open and click tracking

With tracking on, the mailer rewrites each HTML body before sending it:

config/app.toml
  • Clicks. Every <a href="http(s)://..."> becomes {base_url}/mail/t/c/{delivery}?u=<link>&expires=..&signature=... The route records a clicked event with the link and answers 302 to it. mailto: and # links are left alone, and so is any link with a data-no-track attribute.
  • Opens. An image at {base_url}/mail/t/o/{delivery}?expires=..&signature=.. records an opened event and answers with a transparent GIF.

Links are signed with the app's UrlSigner (http.signing_key, or a key derived from app.key), so a link can't be changed to redirect elsewhere: a bad or expired signature is a 404. The routes sit at the root, outside the API prefix. Point base_url at the domain that serves them.

Tracking needs the pipeline, since links name the delivery: with opens or clicks on and mail.delivery.enabled off, the boot fails. It also fails without a base URL. Many mail clients load images through a proxy or block them, so treat opens as a lower bound.

DKIM signing

Mail sent through an API provider is signed by the provider. Mail sent over SMTP to a relay that doesn't sign needs a DKIM-Signature of its own. Give the SMTP transport a key:

config/app.toml
KeyMeaning
domainThe signing domain (d=). Mail from this domain or a subdomain is signed; other mail is sent unsigned.
selectorThe selector (s=): the public key lives at mail._domainkey.acme.test.
private_keyA PEM key: RSA of 2048 bits or more (PKCS#8 or PKCS#1), or Ed25519 (PKCS#8). A key pasted on one line with literal \n works.
headersThe headers to sign; by default From, Reply-To, Subject, Date, To, Cc, Message-ID, MIME-Version, Content-Type and Content-Transfer-Encoding when present. From is always signed.

Signatures use relaxed/relaxed canonicalization, with rsa-sha256 or ed25519-sha256 (RFC 8463). Signing runs on ring. DkimSigner prints the record to publish:

Rust

Tenant SMTP transports take the same dkim table, so each customer's domain signs with its own key.

Sending domains

Before a customer sends from their own domain, check that it publishes what receivers look for. SendingDomains reads the domain's SPF and DMARC records and the DKIM keys you name:

Rust
RecordFails whenWarns when
SPF at the domainthere is none or more than one, an expected include: is missing, it ends in +all, or it needs more than ten DNS lookupsit ends in ?all, or has no all mechanism and no redirect=
DKIM at <selector>._domainkey.<domain>there is none, the key is revoked (empty p=) or isn't base64, or the key type is unknownan RSA key is shorter than 2048 bits
DMARC at _dmarc.<domain>there is none or more than one, it has no policy, or the policy is unknownthe policy is p=none, pct is below 100, or there is no rua= address

Each RecordCheck has a status (pass, warn or fail), the name looked up, the record found and its problems. ready() is true when nothing fails. The report is Serialize, so a settings page can return it as it is. Lookups go through dyn DnsResolver: SystemResolver asks the system's resolvers, and StaticResolver answers from a table in tests.

The command does the same from a terminal:

Shell

It exits with an error when the domain isn't ready, so it can gate a deployment script.

Admin routes

With the pipeline on, MailModule mounts these routes under /api/mail. Each requires the permission mail.manage, so the app needs a route authorizer such as AccessModule:

RouteAnswer
GET /mail/deliveries?workspace_id&status&recipient&template&page&per_pageA page of deliveries, newest first.
GET /mail/deliveries/{id}The delivery with its events.
GET /mail/suppressions?workspace_id&emailA page of suppressions.
POST /mail/suppressions {email, workspace_id?, reason?, detail?}201, the suppression (manual by default).
DELETE /mail/suppressions/{id}204.
GET /mail/outbox?status&workspace_idA page of held mail; status defaults to held, all lists every one.
POST /mail/outbox/{id}/release{status, delivery_id, transport, tier}; 409 unless held.
POST /mail/outbox/{id}/discardThe held mail, now discarded; 409 unless held.
POST /mail/transports/usable (a transport's settings){usable: true, kind}, or 422 mail_transport_unusable.

These are platform routes: they act on every workspace and filter by workspace_id. For screens a workspace admin uses, build routes on the same services behind InWorkspace, as in saving a tenant's transport. Set mail.delivery.routes = false to mount none.

Commands

CommandWhat it does
mail:test <to> [--via <transport>] [--workspace <id>] [--organization <id>] [--subject <text>]Sends a short test message through the scope's transport and prints how it went: sent via ses (workspace) provider_id=... delivery=....
mail:suppressions [list|add|remove] [<email>] [--workspace <id>] [--reason <reason>]Lists, adds or removes suppressed addresses.
mail:domains <domain> [--selector <s>]... [--spf-include <domain>]...Checks a sending domain, as shown above.

Testing

MailFake is a transport that records every message and plays the provider. Install it in place of the transport, then report what the provider would:

tests/contacts.rs
  • sent(), sent_to(address), assert_sent(predicate) and assert_nothing_sent() read what was sent. Each message gets the provider id fake-1, fake-2 and so on.
  • deliver, bounce, soft_bounce, complain, open and click report an event for the last message sent to an address, through Deliveries::record, so the log, suppressions and Mail* events all follow. events() lists what the fake reported.
  • For the API transports, point endpoint at a local mock server: a configured endpoint's host is allowed through the SSRF policy.
  • For domain checks, override dyn DnsResolver with a StaticResolver: StaticResolver::new().record("acme.test", "v=spf1 -all").

Errors

Besides the errors on the mail page:

VariantWhenIn a route
HeaderAn extra header has an invalid name, a line break, or is one the transport writes.400
ProviderAn email API refused the request; carries its status and message.500
UnusableA transport failed its usable check, or names a private SMTP host.422 mail_transport_unusable
NotFoundA delivery, held message, suppression or stored transport doesn't exist.404
ConflictReleasing or discarding mail that is no longer held.409
MissingA feature needs something the app lacks (the pipeline, DatabaseModule, an app key).500
DnsA DNS lookup failed (a missing record is not an error).500
StoreReading or writing the mail tables failed.500