Newsletter module

Module id: newsletter. Dependencies: none.

Two halves that share a subscriber list:

  • Capture: a public double-opt-in subscribe flow. A visitor submits an email, gets a confirmation link, and only counts as a subscriber once they click it.
  • Campaigns: an admin surface for writing a mailing and sending it to every confirmed subscriber, immediately or at a set time.

No dependencies, so a plain content site with no shop can collect addresses and mail them.

The sidebar item (Newsletter, Commerce section) is admin and above. Bulk email is a back-office surface, so editors do not see it. The campaigns screen is the entry point; subscribers and settings link from there.

The subscribe flow

visitor submits email  →  pending  →  clicks link in email  →  confirmed

Only confirmed rows ever receive a campaign. A pending row that is never confirmed simply sits there and costs nothing.

Three things about this are worth knowing before you field a support question:

  • The confirmation link is valid for 48 hours. After that the visitor has to subscribe again, which reissues a fresh link.
  • The result page is always the same. New address, already pending, already confirmed, previously unsubscribed: all four render the same neutral "check your inbox" page. This is on purpose. A page that said "you are already subscribed" would let anyone test whether a given address is on your list.
  • Double opt-in can be turned off (Module settings → Newsletter). The row is then confirmed on submit with no email round trip. It gets you addresses faster and it is the wrong default: without a confirmation click you have no proof of consent, anyone can enter someone else's address, and typo addresses accumulate as bounces that damage the domain's deliverability. Leave it on unless you have a specific reason.

Unsubscribe is one click and needs no login. Every subscriber carries a stable, unguessable token, and the link built from it works straight from any mail client (RFC 8058 List-Unsubscribe), which is also what keeps the mailing out of spam folders at the big providers.

What operators set

Module settings → Newsletter:

Setting Default What it does
Double opt-in on Off confirms on submit, with no email. See above.
Confirmation subject built-in text Subject line of the confirmation email.
From address / name inherits Overrides the website's default sender for newsletter mail only.

The sender resolves as newsletter override → website default → refuse to send. This is the same chain the other mail channels use, so a website whose From: address is set needs nothing here. See security.md → mail-from refusal model.

A campaign whose sender cannot be resolved fails immediately rather than starting and sending nothing.

Campaigns

A campaign is a subject, an HTML body, and an optional plain-text body. It moves through:

draft  →  scheduled  →  sending  →  sent
                            ↓
                          failed
  • draft: editable, not going anywhere.
  • scheduled: a send time is set. A scheduler tick runs every 5 minutes and dispatches campaigns whose time has come, so a campaign scheduled for 09:00 goes out at 09:00 to 09:05, not to the second.
  • sending: the recipient set was snapshotted when the send started. Someone who subscribes mid-send is not included; they will get the next one.
  • sent: finished. The list shows sentCount against totalRecipients.

Draft and scheduled campaigns can still be edited; a sending or sent one cannot.

Why a send takes minutes, not seconds

A large list does not go out in one burst. SMTP relays rate-limit, and a flood tanks deliverability for the whole domain. So a send walks the list in batches of 50, with a 5 second pause between them. A 10,000 address list spreads over roughly 17 minutes.

That is working as intended, not a stuck job. The progress count on the campaign climbs while it runs.

The pipeline is also idempotent: one row is written per recipient, so a worker that crashes and redelivers a batch will not send anyone a second copy. A single address that hard-fails is logged and skipped rather than aborting the run.

This requires a running worker. Campaigns move through Messenger, so if cms-worker is down a send sits in the queue at 0 sent and resumes when the worker comes back.

What a theme has to do

There is a ready-made form to include anywhere:

{% include '@GsidCmsCore/newsletter/_subscribe_form.html.twig' %}

Pass a source to record where the signup came from. It is stored on the subscriber and is what lets you tell a footer signup from a campaign landing page later:

{% include '@GsidCmsCore/newsletter/_subscribe_form.html.twig' with { source: 'footer' } %}

Rolling your own is a plain form, no JavaScript involved:

<form method="post" action="{{ path('cms_newsletter_subscribe') }}">
    <input type="hidden" name="_token" value="{{ csrf_token('cms_newsletter_subscribe') }}">
    <input type="email" name="email" required autocomplete="email">
    <button type="submit">Aanmelden</button>
</form>

The CSRF token name is cms_newsletter_subscribe and the token is required. Post without it and the submission is rejected.

Public routes

Route Name What it does
POST /newsletter/subscribe cms_newsletter_subscribe Capture an address, start opt-in
GET /newsletter/confirm/{token} cms_newsletter_confirm Confirm a pending subscriber
`GET POST /newsletter/unsubscribe/{token}` cms_newsletter_unsubscribe

All three 404 when the module is off.

Overridable templates

@GsidCmsCore/newsletter/_subscribe_form.html.twig   →  @website/newsletter/_subscribe_form.html.twig
@GsidCmsCore/newsletter/subscribe_result.html.twig  →  @website/newsletter/subscribe_result.html.twig
@GsidCmsCore/newsletter/confirm.html.twig           →  @website/newsletter/confirm.html.twig
@GsidCmsCore/newsletter/unsubscribe.html.twig       →  @website/newsletter/unsubscribe.html.twig
@GsidCmsCore/newsletter/email/confirmation.html.twig (and .txt.twig)
@GsidCmsCore/newsletter/email/campaign.html.twig     (and .txt.twig)

The three result pages are the ones worth overriding first: they are the only parts of the flow a visitor sees on your own domain, and the defaults are unstyled.