Meridian handbook
Features

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

driverKeysNotes
log (also empty)noneDevelopment 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.
postmarkserver_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.
sesregion, access_key_id, secret_access_key (secret), configuration_set, notification_topic_arnAmazon SES through its v2 API. Bounces and complaints arrive through the SNS topic named by notification_topic_arn.
resendapi_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.
smtphost, port (default 587), username, password (secret), tlstls: 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: log or no driver;
  • an empty mail.from;
  • driver: postmark without webhook_token, since anyone could otherwise fill the suppression list;
  • driver: resend without api_key or webhook_secret, or a webhook_secret that is not a whsec_ 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.htmlHolds
subjectThe subject line. Whitespace is collapsed. Default: the site’s name.
preheaderThe preview text a mail client shows after the subject.
subject_titleThe HTML <title>.
preheader_htmlThe hidden preview text in the body.
bodyThe message.
footerThe 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:

NameMeaning
site.name, site.urlThe site’s name in the recipient’s locale, and its base address.
recipient.displayName, recipient.email, recipient.localeWho it is for.
localeThe recipient’s locale. Dates and numbers are formatted in it, in the site’s time zone.
nowThe 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

TemplateSent whenBundle
staff-invitationA staff user is invitedboth
staff-password-resetA staff user asks to reset a passwordboth
mentionA staff user is mentioned in a noteboth
notification-digestThe daily digest of a user’s notificationsboth
newsletter-confirmA visitor subscribes: the double opt-in linkboth
newsletterA newsletter campaign is sentboth
magic-linkA member signs in to the portalDMO
portal-invitationA person is invited to an organisation’s portalDMO
claim-verification, claim-contact-check, claim-question, claim-approved-notice, claim-rejectedA listing claim movesDMO
moderation-outcomeA member’s change is approved or returnedDMO
renewal-noticeA membership is due to renewDMO
member-digestThe monthly member reportDMO
organisation-reportA scheduled organisation reportDMO
broadcastA CRM broadcastDMO

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:

ReasonHow
hard_bounceThe provider reports a hard bounce.
complaintThe recipient marks a message as spam.
soft bouncesmail.suppression.soft_bounce_threshold soft bounces (3) within soft_bounce_window (30 days) suppress as a hard bounce.
manual, unsubscribe_allAdded by hand: engine-dmo mail suppression add --reason, or engine-dmo newsletter suppress.
erasureThe 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:

DriverWebhookAuthenticated by
postmarkPOST /_mail/webhooks/postmarkAn Authorization: Bearer <webhook_token> header: add it as a custom header on the Postmark webhook.
sesPOST /_mail/webhooks/sesThe SNS message signature and notification_topic_arn. The subscription confirmation is followed automatically.
resendPOST /_mail/webhooks/resendThe 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

CommandWhat it does
mail statusThe driver, the from address, the webhook path and whether it has received anything, the queue depth, and bounce and complaint figures.
mail testA real send through the driver with sample context: --to, --template (default magic-link), --site, --locale.
mail templatesEvery transactional template for a site (--site), the root that resolved it, and whether a .txt exists.
mail suppression add, check, list, removeThe suppression list.

Configuration reference

Every key is under mail: in config/engine.yaml.

KeyDefaultMeaning
driverlogpostmark, ses, resend, smtp or log.
fromThe sender. Required in production.
reply_toThe Reply-To of every message.
postmark.*, ses.*, resend.*, smtp.*The driver’s keys, above. ses.region has no default here.
webhooks.path/_mail/webhooksThe prefix of the provider webhooks.
templates.diremailsThe directory under each template root.
templates.plain_textgenerategenerate or require.
suppression.soft_bounce_threshold3
suppression.soft_bounce_window30d
suppression.notify_on_suppresstrue
delivery_record_retention13mHow long delivery records are kept (13m is thirteen months).
rate_limit_per_minute0Space 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.