Transactional mail
How to configure the mail driver (Postmark, Amazon SES, Resend, SMTP or the development log), per-site senders, the email templates the engine and your forms send, bounce and complaint webhooks, the suppression list and the mail commands.
The engine sends transactional mail itself: staff invitations and password resets, notifications, form receipts and notices, newsletter confirmations, and in the DMO bundle the member portal’s sign-in links, claims and reports. It never runs mail infrastructure. You bring a provider account or an SMTP relay; in development the log driver writes each message to the log instead of sending it.
Configure a driver
# config/engine.yaml
mail:
driver: ${MAIL_DRIVER:-log}
from: "Riverside Arts <hello@example.org>"
reply_to: ${MAIL_REPLY_TO:-}
postmark:
server_token: ${POSTMARK_SERVER_TOKEN}
message_stream: outbound
webhook_token: ${POSTMARK_WEBHOOK_TOKEN} Then check it end to end:
engine-dmo mail status # driver, sender, webhook path, queue, bounces and complaints
engine-dmo mail test --to you@example.org # a real send of the magic-link template with sample data
engine-dmo mail test --to you@example.org --template contact-receipt --site main mail test prints the provider’s response. Both commands need the database.
Reading mail in development
At the default log level the log driver writes to, subject and tag, with no body, so a sign-in, invitation or claim link cannot be read from it. Either turn the log level up:
# config/engine.yaml
logging:
level: debug which adds a mail body line with the text and its links, or send to a local catcher such as Mailpit, which listens for SMTP on port 1025 and has an inbox at http://localhost:8025:
# config/engine.yaml
mail:
driver: smtp
from: hello@example.org
smtp:
host: localhost
port: 1025
tls: none A mail’s links use one of two origins. A member’s sign-in and claim links use the address the request came in on. An invitation or a membership notice comes from a job with no request, and uses the site’s first domain with no port, so on a development install it reads http://localhost/members/login: open that path on your server’s own address.
Drivers
driver | Keys | Notes |
|---|---|---|
log (also empty) | none | Development only: each message is written to the log, its address, subject and tag at the info level and its body at debug. Refused in production. |
postmark | server_token (secret), message_stream, broadcast_stream, webhook_token (secret) | broadcast_stream (default broadcast) carries newsletters and broadcasts so their reputation stays apart from transactional mail. webhook_token is required in production. |
ses | region, access_key_id, secret_access_key (secret), configuration_set, notification_topic_arn | Amazon SES through its v2 API. Bounces and complaints arrive through the SNS topic named by notification_topic_arn. |
resend | api_key (secret), webhook_secret (secret) | webhook_secret is the endpoint’s whsec_ signing secret from the Resend dashboard. Both are required in production. Resend has no separate streams; send the newsletter from a subdomain if you want its reputation apart. |
smtp | host, port (default 587), username, password (secret), tls | tls: starttls requires STARTTLS; tls: tls connects with TLS (port 465); tls: none sends in clear. Left empty, STARTTLS is used when the relay offers it. TLS 1.2 is the minimum. |
Secrets are bare ${VAR} interpolations. A failed send is retried when the failure is temporary (a timeout, an SMTP 4xx) and recorded as failed when it is final.
Production rules
engine-dmo check refuses, when engine.environment is production:
driver: logor no driver;- an empty
mail.from; driver: postmarkwithoutwebhook_token, since anyone could otherwise fill the suppression list;driver: resendwithoutapi_keyorwebhook_secret, or awebhook_secretthat is not awhsec_secret.
These rules run in engine-dmo check only, not when the server starts: an engine started in production on the log driver boots and writes every message to its log instead of sending it. Run ENVIRONMENT=production engine-dmo check (with your production variables) in the deploy pipeline before the new version starts.
In every environment, from and reply_to must be an address (hello@example.org) or a name and an address (Riverside Arts <hello@example.org>).
Senders per site
A message sent for a site goes out from the site’s own address when its site.yaml sets one:
# config/sites/<site>/site.yaml
mail:
from: "Riverside Arts <hello@example.org>"
reply_to: box-office@example.org
newsletter:
from_address: news@example.org
from_name: Riverside Arts from and reply_to replace mail.from and mail.reply_to for that site’s transactional mail. When from has no name, the site’s name is used. The newsletter block replaces the newsletter’s sender for the site’s campaigns (Newsletters). A site that sets nothing sends as the install does.
Templates
Every message is a template in an emails/ directory, resolved along the site’s template chain like a page: templates/<site>/emails/ first, then templates/_shared/emails/, then the engine’s own default. A project template with the same name as a default replaces it. Where a message or the CLI says “templates/emails/”, it means the emails/ directory of any of these roots.
A template extends emails/_layout.html and fills its blocks. The engine renders it three times, with part set to subject, preheader and html, so one file yields every part:
{% extends "emails/_layout.html" %}
{% block subject %}We have your message{% endblock %}
{% block preheader %}A copy of what you sent.{% endblock %}
{% block body %}
<h1>Thank you, {{ name }}</h1>
<p>We will reply to {{ email }} within two working days.</p>
{% endblock %} Block of _layout.html | Holds |
|---|---|
subject | The subject line. Whitespace is collapsed. Default: the site’s name. |
preheader | The preview text a mail client shows after the subject. |
subject_title | The HTML <title>. |
preheader_html | The hidden preview text in the body. |
body | The message. |
footer | The line under the card. Default: “Sent by” the site’s name and address. |
The default layout is a single-column card that sets lang, a dark-mode palette and role="article". To restyle every message, write your own templates/_shared/emails/_layout.html that switches on part the same way.
Plain text. A message’s text alternative is emails/<name>.txt when it exists, else generated from the HTML. With mail.templates.plain_text: require, a template without a .txt is an error.
Context. Every template receives:
| Name | Meaning |
|---|---|
site.name, site.url | The site’s name in the recipient’s locale, and its base address. |
recipient.displayName, recipient.email, recipient.locale | Who it is for. |
locale | The recipient’s locale. Dates and numbers are formatted in it, in the site’s time zone. |
now | The time of rendering. |
plus what the sender adds: a form’s inputs and submission for a mail step (Forms), and named values for each engine template.
Locale. A form’s mail, the staff notice included, is rendered in the locale the visitor posted in. A message sent with no locale is formatted in the site’s default locale.
No page functions. Email templates have the template language and its filters, but not the functions a page has: t(), query(), head() and the rest are not defined there. Calling one prints nothing, and check does not report it. Write text for each locale by branching on locale:
{% block subject %}{% if locale == "cy" %}Diolch am eich neges{% else %}Thank you for your message{% endif %}{% endblock %} Checked. engine-dmo check compiles every engine template, and every template a mail step names, against sample data, so a broken email fails the check rather than a send. engine-dmo mail templates lists every template, the root that resolved it and whether a .txt exists.
Transactional mail carries no unsubscribe footer. Newsletters and broadcasts do, and cannot be sent without one.
The engine’s templates
| Template | Sent when | Bundle |
|---|---|---|
staff-invitation | A staff user is invited | both |
staff-password-reset | A staff user asks to reset a password | both |
mention | A staff user is mentioned in a note | both |
notification-digest | The daily digest of a user’s notifications | both |
newsletter-confirm | A visitor subscribes: the double opt-in link | both |
newsletter | A newsletter campaign is sent | both |
magic-link | A member signs in to the portal | DMO |
portal-invitation | A person is invited to an organisation’s portal | DMO |
claim-verification, claim-contact-check, claim-question, claim-approved-notice, claim-rejected | A listing claim moves | DMO |
moderation-outcome | A member’s change is approved or returned | DMO |
renewal-notice | A membership is due to renew | DMO |
member-digest | The monthly member report | DMO |
organisation-report | A scheduled organisation report | DMO |
broadcast | A CRM broadcast | DMO |
Override one by writing a template of the same name. engine-dmo mail test --template <name> renders and sends it with the sample data check uses.
Bounces, complaints and suppression
Every send checks the suppression list first. A suppressed address is never sent to: no job is queued and the delivery record says why.
Addresses are suppressed install-wide, on every channel, by:
| Reason | How |
|---|---|
hard_bounce | The provider reports a hard bounce. |
complaint | The recipient marks a message as spam. |
| soft bounces | mail.suppression.soft_bounce_threshold soft bounces (3) within soft_bounce_window (30 days) suppress as a hard bounce. |
manual, unsubscribe_all | Added by hand: engine-dmo mail suppression add --reason, or engine-dmo newsletter suppress. |
erasure | The person’s data was erased. Cannot be removed. |
Providers report bounces, complaints and deliveries to a webhook on the engine. Point your provider at the path for your driver:
| Driver | Webhook | Authenticated by |
|---|---|---|
postmark | POST /_mail/webhooks/postmark | An Authorization: Bearer <webhook_token> header: add it as a custom header on the Postmark webhook. |
ses | POST /_mail/webhooks/ses | The SNS message signature and notification_topic_arn. The subscription confirmation is followed automatically. |
resend | POST /_mail/webhooks/resend | The Svix signature, with webhook_secret. |
mail.webhooks.path changes the prefix. Only the configured driver’s path answers. With notify_on_suppress, staff are notified when a hard bounce suppresses the address of a CRM contact (DMO bundle).
engine-dmo mail suppression check --email someone@example.org
engine-dmo mail suppression list --reason hard_bounce --since 30d
engine-dmo mail suppression add --email someone@example.org --reason manual
engine-dmo mail suppression remove --email someone@example.org --reason "asked to be re-added" --confirm list shows addresses as hashes. remove is audited; an erasure row cannot be removed.
Delivery records
Every message has a delivery record: the template, the recipient, the provider’s message id and what the provider reported (sent, delivered, bounced, complained, failed, suppressed). Records are kept for mail.delivery_record_retention, thirteen months by default. A form’s submission shows the records of the mail it sent.
Queued messages that carry single-use links (invitations, resets, sign-in links) are encrypted in the job queue.
Commands
| Command | What it does |
|---|---|
mail status | The driver, the from address, the webhook path and whether it has received anything, the queue depth, and bounce and complaint figures. |
mail test | A real send through the driver with sample context: --to, --template (default magic-link), --site, --locale. |
mail templates | Every transactional template for a site (--site), the root that resolved it, and whether a .txt exists. |
mail suppression add, check, list, remove | The suppression list. |
Configuration reference
Every key is under mail: in config/engine.yaml.
| Key | Default | Meaning |
|---|---|---|
driver | log | postmark, ses, resend, smtp or log. |
from | The sender. Required in production. | |
reply_to | The Reply-To of every message. | |
postmark.*, ses.*, resend.*, smtp.* | The driver’s keys, above. ses.region has no default here. | |
webhooks.path | /_mail/webhooks | The prefix of the provider webhooks. |
templates.dir | emails | The directory under each template root. |
templates.plain_text | generate | generate or require. |
suppression.soft_bounce_threshold | 3 | |
suppression.soft_bounce_window | 30d | |
suppression.notify_on_suppress | true | |
delivery_record_retention | 13m | How long delivery records are kept (13m is thirteen months). |
rate_limit_per_minute | 0 | Space sends to at most this many a minute per process; 0 is no limit. |
broadcast.* | The DMO bundle’s CRM broadcasts: batch_size (50, 1 to 1000), batch_pause (5s, up to 1h), open_tracking (false), per_recipient_tracking (false). unsubscribe_footer cannot be false. |
retention.mail_events (thirteen months) is how long provider events are kept, and must be at least suppression.soft_bounce_window.
Forms and submissions
How to build a contact or enquiry form with no code: declaring an endpoint in endpoints.yaml, its inputs, validation, spam protection and rate limits, the store and mail steps, retention, the drawn form, the response templates and the submissions inbox.
Newsletters
How the built-in newsletter works: capturing subscribers with double opt-in through a declared endpoint, topics and consent records, the confirmation and preference centre pages, composing and sending campaigns from live content, tracking, deliverability checks and the newsletter commands.