Meridian handbook
Features

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.

The newsletter is built in, in both bundles. Visitors subscribe through a form you declare; each subscription is confirmed by the subscriber (double opt-in) and recorded with the exact wording they agreed to. Staff compose campaigns in the admin from text and live content blocks, send them to a segment of a site’s subscribers through your mail provider, and read a report of deliveries, clicks and the referrals they produced. Subscribers manage their topics, frequency and pauses in a preference centre behind a signed link.

Campaigns go out through the configured mail driver (Transactional mail), on the provider’s marketing stream where it has one.

Capture subscribers

Declare a subscription endpoint with a subscribe step:

# config/sites/<site>/endpoints.yaml
endpoints:
  - key: newsletter
    path: /newsletter
    methods: [post]
    input:
      email:   { type: text, format: email, required: true, autocomplete: email, label: { en: Your email address } }
      topic:   { type: select, options: [exhibitions, family], label: { en: What interests you? } }
      consent: { type: boolean, required_value: true, label: { en: Yes, send me the monthly programme } }
    steps:
      - subscribe: { form: newsletter, email: $email, tags: [$topic] }
    respond:
      fragment: partials/subscribed.html
      on_error: partials/subscribe-form.html

List the topics subscribers may choose, in engine.yaml:

# config/engine.yaml
newsletter:
  from_address: news@example.org
  from_name: Riverside Arts
  topics: [exhibitions, family]

Draw the form, for example in the footer of every page, and write the two partials:

{# templates/_shared/partials/subscribe-form.html #}
{{ subscribeForm("newsletter").render(submit="Sign up") }}
{# templates/_shared/partials/subscribed.html #}
<div id="form-newsletter" role="status"><h2>Check your email</h2><p>Open the link we sent to confirm.</p></div>

Run engine-dmo check --fix, then engine-dmo check.

The endpoint takes every other endpoint key, rate limits and spam controls included (Forms).

The subscribe step

KeyMeaning
emailRequired. The input holding the address, $email.
nameThe input holding the subscriber’s name.
formA name for where the subscription came from, recorded with the consent.
tagsThe topics subscribed to: literal topic keys, or $input references to an input whose posted value (a string or a list) is matched against newsletter.topics. Anything not in topics is dropped silently, so a visitor’s free text never becomes a tag.

The consent recorded is the label of the endpoint’s required_value boolean, in the visitor’s locale, so word that checkbox as the promise you are making. The source recorded is the page the form was on (the referring path on the site’s own host, else the endpoint’s path), never a foreign address or a query string.

Double opt-in

A post creates a pending subscriber and mails the newsletter-confirm template with a signed link to /newsletter/confirm/<token> on the site. The link works for seven days. A second post for the same pending address within a day sends nothing more, and answers exactly as the first did, so the form cannot be used to flood an inbox.

The link opens a page with one button; the button’s post confirms. A mail scanner that follows the link confirms nothing. Unconfirmed subscribers are deleted after newsletter.pending_expiry (14 days), with their consent records.

newsletter.double_opt_in cannot be set to false.

The subscriber’s pages

Each page renders your template when the site has one, along its template chain, and a plain built-in page otherwise. All of them answer Cache-Control: no-store and X-Robots-Tag: noindex.

Confirmation

newsletter/confirm.html is the page with the button. It receives subscriber (email, status: pending, confirmed: false) and confirm (action, the path to post to, and csrf, the token for the _csrf field):

{# templates/_shared/newsletter/confirm.html #}
{% extends "base.html" %}
{% block body %}
<h1>Confirm your subscription</h1>
<p>Confirm that {{ subscriber.email }} should receive our newsletter.</p>
<form method="post" action="{{ confirm.action|url }}">
  <input type="hidden" name="_csrf" value="{{ confirm.csrf }}">
  <button type="submit">Confirm</button>
</form>
{% endblock %}

newsletter/confirmed.html is the outcome. subscriber.status is active with confirmed: true when this post confirmed it, active with confirmed: false when the address was already confirmed, and expired (with no email) when the link does not verify, has run out or names an address that has left.

The preference centre

At newsletter.preference_centre_path (/newsletter/preferences) with the subscriber’s signed token, linked from every campaign’s footer. It offers “Unsubscribe from everything”, the topics of newsletter.topics (labelled from the tag vocabulary, else the key), the frequency (every, weekly, monthly) and a pause of one, three or six months.

newsletter/preferences.html receives preferences:

KeyMeaning
statusunknown (a link that opens nothing), active, pending, unsubscribed, bounced, complained or suppressed.
subscribedActive or pending.
emailThe address.
topicsEach with key, label and chosen.
frequency, frequenciesThe chosen frequency and the choices.
pausedUntil, pausesThe pause end date (empty when not paused) and [1, 3, 6].
noticeWhat the last post did: saved, refused (with error), unsubscribed, or empty.
action, csrfWhere the form posts (the path carries the token) and a CSRF token.

The form posts action=save with topic (repeated), frequency and pause (0 sends again or resumes, 1, 3 or 6 pauses, -1 leaves a pause as it is), or action=unsubscribe. An unknown token renders the same template with status: unknown and nothing else, so addresses cannot be probed. The token does not expire and names one subscriber.

Unsubscribe

Every campaign carries an unsubscribe link at /_n/u/<token> and the List-Unsubscribe headers for one-click unsubscribe. A GET shows a page with one button (mail scanners must not unsubscribe anyone); the button’s post, or the mail client’s one-click post, unsubscribes. Unsubscribing records a withdrawal of consent with how it was made.

Campaigns

Not in this version: an image block, A/B tests, sending at each recipient’s own local time, and automated sequences (a welcome series, a drip). The preference centre has no language choice and offers no download or deletion of the subscriber’s data: staff answer those requests by hand (engine-dmo newsletter export for a copy, engine-dmo newsletter suppress to stop all mail).

Campaigns are composed in the admin under Newsletter, Campaigns. A campaign belongs to one site and has a subject, a preheader, a key (the utm_campaign its links carry) and a list of blocks:

BlockHolds
TextMarkdown, through the site’s safe subset.
ButtonA label and an address.
Latest documentsThe newest published documents of a type (three by default, at most twenty).
Smart listA public document smart list, as the site shows it.
Events coming upDocuments with a published, uncancelled occurrence in the next N days (seven by default).
One documentA document chosen by search.

Live blocks read published content at the moment of the preview, the test or the send, through the public site’s own reads, so a draft or a page the site does not show never reaches a campaign. A test send goes to the composer with “[Test]” before the subject.

The template

A campaign renders as emails/newsletter.html along the site’s template chain; the engine ships a default. It receives campaign and subscriber.name with the common mail context:

campaign keyMeaning
subject, preheader, keyAs composed.
blocksIn order, each with kind (text, button, latest, list, events, document), title, html for text, label and url for a button, and entries (each title, summary, url) for the live ones. Links are already tracked and carry the campaign’s parameters.
unsubscribeUrlRequired: a message that does not link it is refused before anything is queued, and engine-dmo check refuses a template without it.
preferencesUrlThe preference centre.
trackedTrue when links are counted, so the footer can say so.
{# templates/_shared/emails/newsletter.html #}
{% extends "emails/_layout.html" %}
{% block subject %}{{ campaign.subject }}{% endblock %}
{% block preheader %}{{ campaign.preheader }}{% endblock %}
{% block body %}
<h1>{{ campaign.subject }}</h1>
{% for b in campaign.blocks %}
  {% if b.title %}<h2>{{ b.title }}</h2>{% endif %}
  {% if b.kind == "text" %}{{ b.html }}
  {% elif b.kind == "button" %}<p><a class="button" href="{{ b.url|url }}">{{ b.label }}</a></p>
  {% else %}{% for e in b.entries %}<h3><a href="{{ e.url|url }}">{{ e.title }}</a></h3>{% if e.summary %}<p>{{ e.summary }}</p>{% endif %}{% endfor %}{% endif %}
{% endfor %}
{% endblock %}
{% block footer %}<a href="{{ campaign.preferencesUrl|url }}">Manage your preferences</a> or <a href="{{ campaign.unsubscribeUrl|url }}">unsubscribe</a>.{% endblock %}

Audience

A campaign’s segment is its site’s subscribers with any of its topics (or every one, with match all) whose frequency is one the campaign is for; no topics or frequencies is everyone. Frequency is a filter, not a timer: a monthly roundup is a campaign for every, weekly and monthly.

From the segment, pending, unsubscribed, withdrawn-consent, paused, bounced, complained and suppressed subscribers are left out when the recipients are taken. The composer counts the recipients and each reason. Each address receives a campaign once.

Sending

Sending needs newsletter.send, a ticked confirmation of the count and, while newsletter.require_domain_verification is true (the default), a sending domain the deliverability check has verified. A campaign can be sent now or scheduled (in the site’s time zone), returned to draft, or cancelled. Recipients are queued at newsletter.send_rate_per_minute; a retry or a second server never sends anyone twice. A subscriber who unsubscribes after the send starts is skipped.

engine-dmo newsletter campaigns --site main --status draft
engine-dmo newsletter send --campaign <id> --confirm

Tracking and the report

Every link to the site carries utm_source=newsletter, utm_medium=email and utm_campaign=<key>. With click_tracking (the default), links first pass through a first-party redirect at /_n/c/<token>, which counts the click and redirects to the address stored with the campaign, never one taken from the request. Opens are not tracked unless open_tracking is true, which adds a pixel at /_n/o/<token>.gif.

The report shows recipients, delivered, awaiting delivery, bounced, complaints, not sent, unsubscribed, clicks and clickers, clicks per link, who the audience left out, and, in the DMO bundle, the referrals the campaign sent to member businesses.

Deliverability

The admin’s Newsletter, Deliverability screen and engine-dmo newsletter deliverability check each site’s sending domain (the domain of the site’s newsletter sender, else newsletter.from_address, else mail.from) and show what was found and the exact record to add:

CheckPasses when
SenderA sender is configured and the driver sends.
SPFOne v=spf1 record carrying the provider’s include (spf.mtasv.net for Postmark, amazonses.com for SES and Resend; Resend’s on send.<domain>).
DKIMA published key at <selector>._domainkey.<domain> for each of dkim_selectors (Resend’s own resend selector when the list is empty and the driver is resend).
DMARCA v=DMARC1 record at _dmarc.<domain>. p=none passes with a warning.

Passing every check verifies the domain; a later failure clears it, which blocks the next campaign. The screen also shows the newsletter’s 30-day complaint rate and warns from 0.08 percent.

Subscribers from the command line

engine-dmo newsletter subscribers --site main --status active
engine-dmo newsletter subscribers --site main --dormant 12m
engine-dmo newsletter import --site main --file list.csv --basis consent --dry-run
engine-dmo newsletter export --site main --topics family --out family.csv
engine-dmo newsletter hygiene --site main --report
engine-dmo newsletter suppress --email someone@example.org --reason manual
CommandWhat it does
subscribersLists a site’s subscribers, by --status (pending, active, unsubscribed, bounced, complained, suppressed); --dormant 12m lists active ones with no click in that period.
importImports a CSV with an email column and optionally name, locale, topics and consented_at, under a declared --basis (consent, legitimate_interest or contract) recorded on every row. Never adds a suppressed address and leaves an existing one as it is. --dry-run counts each outcome.
exportExports a site’s subscribers, or with --topics a segment, as CSV. Audited with the row count.
hygieneActive, dormant, never confirmed, unsubscribed and bounced counts.
campaignsA site’s campaigns, by --status (draft, scheduled, sending, sent, cancelled).
sendSends a draft campaign now; needs --confirm.
deliverabilityRuns the domain checks and records the results.
suppressSuppresses an address on every channel, as mail suppression add.

All need the database.

Permissions

PermissionAllowsBuilt-in roles
newsletter.viewCampaigns, reports, subscribers, deliverabilityAdministrator, Editor, Analyst
newsletter.draftCreate, compose, preview and test-sendAdministrator, Editor
newsletter.sendSchedule, send and cancelAdministrator
newsletter.manageImport, re-check the domain, preferencesAdministrator
newsletter.exportExport, auditedAdministrator

Configuration reference

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

KeyDefaultMeaning
from_addressmail.fromA bare address. Its domain is the one the deliverability check verifies.
from_namethe site’s nameOne line of plain text.
reply_tomail.reply_toA bare address.
double_opt_intrueCannot be false.
pending_expiry14dUnconfirmed subscribers are deleted after this; 1d to 90d.
preference_centre_path/newsletter/preferencesStarts with /, not /_, no trailing slash.
click_trackingtrueCount clicks through the first-party redirect.
open_trackingfalseAdd an open pixel.
send_rate_per_minute6001 to 100000.
dormant_after12mAn active subscriber with no click for this long is dormant; at least 30d.
require_domain_verificationtrueA campaign is refused until its sending domain passes the checks.
topics[]The topic keys subscribers may choose; lowercase keys, each once.
dkim_selectors[]The DKIM selectors your provider gave you.

A site’s mail.newsletter block in site.yaml (from_address, from_name, reply_to, dkim_selectors) replaces these for that site’s campaigns, and its mail.from is used before the install’s from_address.