mspacemedia / silverstripe-newsletter
Reusable newsletter module: compose MailChimp-style drag-and-drop emails with Elemental blocks, manage CMS-defined audiences with CSV import/export, and send in batches over SMTP.
Package info
github.com/mspacemedia/silverstripe-newsletter
Type:silverstripe-vendormodule
pkg:composer/mspacemedia/silverstripe-newsletter
Requires
- php: ^8.1
- dnadesign/silverstripe-elemental: ^5.0
- lekoala/silverstripe-cms-actions: ^1.7
- pelago/emogrifier: ^7.0
- silverstripe/cms: ^5.4
- silverstripe/vendor-plugin: ^2.0
- symbiote/silverstripe-queuedjobs: ^5.0
- tractorcow/silverstripe-colorpicker: ^4.2
Requires (Dev)
- phpunit/phpunit: ^9.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Compose MailChimp-style, drag-and-drop HTML newsletters in the CMS from Elemental blocks, manage CMS-defined audiences, brand them from a central theme, send them in batches over SMTP, and track opens/clicks/bounces using Silverstripe's native CMS preview pane in the editor.
Version: 1.0.5 Requires: SilverStripe 5.4 · PHP 8.1+
Contents
- Features
- Installation
- Concepts
- The block library
- Branding / theme
- Audiences, subscribers & CSV import/export
- Dynamic audiences (source providers)
- Computed merge fields
- Audience segments
- Sending
- Tracking (opens & clicks)
- Bounce handling
- Preview
- Routes
- Developing the module
Features
- Elemental block composer with a full block set (header, headings, text, images, buttons, columns, social, video, code, footer, …) and inline editing.
- Central brand/theme (
NewsletterBrand) — fonts, colours, button shape, logo; every block inherits it and can override per-block. - CMS-defined audiences with CSV import/export, plus pluggable source providers to populate audiences from a host project's own models.
- Instant test send and batched bulk send (QueuedJobs) over SMTP, with retry +
throttle (
MailHelper) and aList-Unsubscribeheader. - Open & click tracking, per-issue engagement stats, and a CMS stats panel.
- Bounce handling by piping DSNs to a task (phase-1; webhook-free).
- Native CMS preview with Silverstripe's built-in device switching, dirty block refreshes, and an unsaved-changes banner.
- Sent issue snapshots so delivered newsletters and view-online pages do not change after a live send starts.
Installation
-
In the project root
composer.json, add the path repository and require it, or via command line:composer require mspacemedia/silverstripe-newsletter
A default NewsletterBrand record is created automatically on dev/build.
Permissions: everything is gated behind the
MANAGE_NEWSLETTERSpermission. Grant it to the groups that should manage newsletters (Security → Groups → Roles).
Concepts
| Model | Role |
|---|---|
NewsletterIssue |
A single newsletter. Holds an Elemental area, targets audiences, carries send status + stats. |
NewsletterAudience |
A mailing list. Subscribers are attached manually, by CSV, or by a source provider. |
NewsletterSubscriber |
A recipient (unique by email). Belongs to many audiences. Status: Active / Unsubscribed / Bounced (global suppression). |
NewsletterBrand |
Global styling tokens (the "theme"). One record per install, editable in the CMS. |
NewsletterSendRecord |
Per-recipient delivery + engagement ledger (sent/failed/bounced, opens, clicks). |
All are managed under the Newsletters admin section.
The block library
Issues are composed from Elemental blocks (edited inline). Available blocks:
Header (brand logo, overridable) · Heading · Text · Boxed text · Columns
(2–3) · Image · Image + caption · Image group · Image card · Button ·
Video (links out) · Social follow · Logo · Custom HTML · Divider · Spacer
· Footer (address + view-online/unsubscribe links).
Every block has an Appearance tab with per-block overrides — padding, alignment, font family, background colour, text colour, link colour (blank = inherit the brand), full-width edge-to-edge toggle (used by the Image block for full-bleed images), and hide-on-mobile. Buttons additionally allow a per-block corner radius.
The blocks render to email-safe, table-based HTML; CSS is inlined at send time (Emogrifier). These blocks are restricted to newsletters — they don't appear in normal page editors.
Branding / theme
NewsletterBrand holds the global look: font family, primary/link/heading/body
colours, page + content background, content width, button colour/text/radius/padding,
divider colour, footer colour, and a header logo.
-
Edit it in Newsletters → Brands / themes.
-
Blocks inherit these values; a block only overrides what you set on it.
-
An issue can pick a specific brand (the Brand / theme dropdown), or use the default.
-
The module ships neutral defaults. A host project can seed its own palette by overriding the config defaults, e.g.:
# app/_config/newsletter.yml MSpaceMedia\Newsletter\Model\NewsletterBrand: defaults: PrimaryColor: '#9c1d44' LinkColor: '#9c1d44' ButtonColor: '#9c1d44' BodyBackground: '#faf6ee'
The seeded record remains fully editable in the CMS afterwards.
Audiences, subscribers & CSV import/export
- Create audiences in Newsletters → Audiences.
- Export: each audience's subscriber grid has an Export button.
- Import: if
i-lateral/silverstripe-importexportis installed, each audience's subscriber grid gains a CSV importer that adds rows to that audience, deduped by email (existing subscribers are reused, not duplicated). - Suppression is global per email — an unsubscribed or bounced subscriber is skipped by every audience's sends and is never silently re-activated.
Subscribers carry name/email plus arbitrary merge data (custom JSON keys). These personalise
content through the {{ … }} merge-field engine — e.g. {{ FirstName }},
{{ Email }}, and any custom key as {{ MYKEY }}. Unsubscribe and view-online links are provided
by the Footer block.
Personalisation resolves late: a live send stores the rendered issue HTML before recipient-specific values are substituted, then each delivery (and recipient-specific view-online request) resolves the merge fields for that subscriber.
Dynamic audiences (source providers)
A host project can feed an audience from its own data by implementing
MSpaceMedia\Newsletter\Source\AudienceSourceProvider:
interface AudienceSourceProvider { public function getKey(): string; // matches NewsletterAudience.SourceKey public function getTitle(): string; // used if the audience is auto-created public function getSubscribers(): iterable; // yields rows (see below) }
Each yielded row:
[
'Email' => 'person@example.com', // required
'FirstName' => 'Jane', // optional
'Surname' => 'Doe', // optional
'MergeData' => ['CITY' => 'Leeds'], // optional, custom merge tags
'Anchor' => $member, // optional DataObject; see Computed merge fields
'Consent' => true, // optional, default true; false = skip
]
Register providers in config and run the refresh task:
MSpaceMedia\Newsletter\Task\NewsletterAudienceRefreshTask: providers: - App\Newsletter\MyOrderAudienceProvider
php vendor/bin/sake dev/tasks/NewsletterAudienceRefreshTask
The task upserts subscribers (deduped by email) into the matching audience, refreshes
name/merge data, links the optional Anchor, and never re-activates unsubscribed/bounced
records. Audiences with no provider are simply manual/CSV-only.
Returning an Anchor (any DataObject, typically the project's Member) is what lights up
the computed merge fields below — it stores the polymorphic
NewsletterSubscriber.Anchor that {{ … }} expressions traverse. A provider that builds an
audience from members therefore needs nothing extra:
public function getSubscribers(): iterable { foreach (Member::get()->filter('NewsletterOptIn', true) as $member) { yield [ 'Email' => $member->Email, 'FirstName' => $member->FirstName, 'Surname' => $member->Surname, 'Anchor' => $member, // {{ Orders.Sum(...) }} now resolves per recipient ]; } }
Subscription API (for project glue such as account pages / checkout):
MSpaceMedia\Newsletter\Service\NewsletterSubscriptionManagerprovidessubscribe($email, $audienceKey, $data),unsubscribe($email),bounce($email)andisSubscribed($email). The$dataarray also accepts an'Anchor' => $recordto link the subscriber for computed merge fields.unsubscribe()fires anonNewsletterUnsubscribeextension hook so a project can reflect the change back onto its own models.
Computed merge fields
Editors personalise content with {{ … }} merge fields — both simple values ({{ FirstName }})
and computed fields that derive a value per recipient from the project's own data: totals,
counts, filtered aggregates, with maths and conditionals. The drag-and-drop-friendly equivalent of
MailChimp's merge tags.
Examples:
{{ Orders.Sum(TotalDonation) | currency }} → £1,330.00
{{ Orders.Count }} → 35
{{ Orders.Where(Status = 'Paid').Count }} → 12
{{ Concat(FirstName, ' ', Surname) }} → Jane Doe
{{#if Orders.Count}}You've donated {{ Orders.Count }} times{{else}}no orders placed{{/if}}
The anchor
Every computed field traverses from the subscriber's anchor — a record linked via the
polymorphic NewsletterSubscriber.Anchor (set by a source provider or the subscription manager,
above). The anchor is usually a Member; configure its type so the visual builder knows what to
introspect:
MSpaceMedia\Newsletter\Model\NewsletterSubscriber: anchor_class: SilverStripe\Security\Member
A subscriber with no anchor isn't an error: every computed field resolves to empty, so | default(…)
and {{#if}} fallbacks take over (e.g. "no orders placed").
Defining a field
Create fields under Newsletters → Merge fields. Each has a Tag (used as {{ TAG }}) and an
Expression. The expression editor offers relation/field pickers and a live preview that
evaluates against a random sample record — so you build the output visually and see a real value as
you go. A defined tag can be referenced from other expressions, and tags resolve in this order:
defined field → built-in (FirstName, Surname, Email, plus the subscriber's custom merge data)
→ a field/relation on the anchor.
Expression language
| Construct | Example | Notes |
|---|---|---|
| Field | FirstName |
Built-in or an anchor field |
| Relation count | Orders.Count |
has_many / many_many |
| Aggregate | Orders.Sum(Amount) |
also Avg / Min / Max |
| Filtered | Orders.Where(Status = 'Paid').Sum(Amount) |
=, !=, >, <, >=, <= |
| Maths | Orders.Sum(Amount) / Orders.Count |
+ - * /, parentheses |
| Functions | Concat(...), Select(cond, a, b), Coalesce(a, b), Round(x, 2), Upper, Lower |
inline value picker is Select (If is a legacy alias) |
| Filters (pipe) | … | currency, … | number(2), … | default('n/a'), … | date('d/m/Y') |
|
| Conditional block | {{#if expr}}…{{else}}…{{/if}} |
nestable |
The table above is the common subset; the complete expression-language reference
lists every literal, operator, aggregate (incl. First), function, filter, built-in tag, and the
resolution/truthiness rules.
Exposing data (required) — the allowlist
The engine is default-deny: it can only traverse relations/fields a project has explicitly exposed. Nothing resolves until you opt in, which also keeps editors away from sensitive fields (password hashes, tokens, …):
MSpaceMedia\Newsletter\Service\MergeFieldService: currency_symbol: '£' allowlist: SilverStripe\Security\Member: relations: [Orders] fields: [FirstName, Surname, Email] App\Shop\Order: fields: [TotalDonation, Amount, Status, Created]
Entries on a parent class cover its subclasses. The visual builder only offers allowlisted paths, and
the evaluator re-checks the same list at send time — the UI is a convenience, never the security
boundary. Aggregates and Where() run as parameterised ORM queries (no eval, no raw SQL), and a
broken or unauthorised tag renders empty rather than leaking an error into the email.
When it resolves
Computed fields resolve per recipient at send time — so the locked sent snapshot
stays generic and each delivery (and recipient-specific view-online page) personalises from it.
They are left as literal {{ … }} in the snapshot.
Audience segments
The same expression engine powers segments: an audience can define a boolean expression that selects active subscribers into itself. A segment is therefore just a normal audience, so issues target it like any other — no special send path.
Any audience can become a segment by setting a Segment expression (Segment tab). It's evaluated per subscriber against their anchor, and a truthy result includes them:
Orders.Count >= 5 → subscribers whose member has 5+ orders
Orders.Where(Status = 'Paid').Count > 0 → has at least one paid order
Orders.Sum(Amount) >= 1000 → £1,000+ lifetime value
LIFETIMEDONATION > 5 && ORDERCOUNT >= 2 → combine conditions with && / ||
- Build / refresh members — the button on a segment audience recomputes membership immediately (no task/cron needed). It adds new matches and drops subscribers that no longer qualify.
- Estimate matches — the builder shows "matches N of M active subscribers" before you commit.
- Evaluate within — optionally scope the pool to another audience (segment inside an audience); empty means all active subscribers.
- Membership is materialised (stored), so sending is unchanged and fast. Re-run the build before a
send to refresh it (or call
NewsletterSegmentService::build($audience)from a hook/onBeforeSend).
Subscribers with no anchor, and suppressed (unsubscribed/bounced) subscribers, are never matched.
Performance
Membership is evaluated row-by-row (one pass over active subscribers), which is fine for periodic
rebuilds. For the common Relation.Count|Sum|Avg|Min|Max <op> number shape (with >, >= or =)
the service instead runs a single grouped query, avoiding a query per subscriber on large lists;
results are identical to the row-by-row path. Anything more complex (Where(), maths, functions,
many-to-many relations) uses the row-by-row evaluator.
Security
Identical to computed fields: only allowlisted relations/fields are traversable, via parameterised queries.
Sending
Open an issue, compose it, choose its audience(s) and brand, then use the buttons in the CMS:
- Send test to me — renders and sends a single
[TEST]copy to the configured admin email immediately (synchronous; no queue runner needed). - Send to audiences — queues a batched
NewsletterSendJob(250 per batch, chained) that sends to every active subscriber in the targeted audiences, each viaMailHelper(3× retry + throttle), records aNewsletterSendRecord, and stamps aList-Unsubscribeheader. The issue moves Draft → Queued → Sending → Sent.
When a live send starts, the issue is locked:
- A
NewsletterIssue.SentHTMLsnapshot is captured and used for delivery and view-online rendering. - The issue and its Elemental blocks are no longer editable or deletable once it has moved beyond Draft.
- Bulk sends resolve merge tags and tracking links from the locked snapshot, so later CMS changes cannot alter already-sent newsletters.
CLI equivalent:
php vendor/bin/sake dev/tasks/NewsletterSendTask?id=<IssueID> # live send php vendor/bin/sake dev/tasks/NewsletterSendTask?id=<IssueID>&test=true # test send
SMTP transport is the project's
SS_MAILER_DSN. In dev, use a catcher / thesend_all_emails_tosafety net so tests never reach real recipients.
Tracking (opens & clicks)
- A 1×1 open pixel is embedded per recipient (
newsletter/open/<token>.png). - Outbound links are rewritten through a click redirector
(
newsletter/click/<token>?u=…); internal/unsubscribe/mailto links are left alone. - Counts land on
NewsletterSendRecord(opens, clicks, first/last opened). - Each issue exposes aggregates (
getSentCount,getOpenedCount,getClickedCount,getBouncedCount,getOpenRate,getClickRate) and a Statistics tab in the CMS.
Open tracking is best-effort (image-blocking inflates "not opened"); clicks are the firmer signal.
Bounce handling
Phase-1, webhook-free. Pipe bounce DSNs (from a bounces@ forwarder) to the task:
… bounce mail … | php vendor/bin/sake dev/tasks/NewsletterBounceTask
# or, for testing:
php vendor/bin/sake dev/tasks/NewsletterBounceTask?file=/path/to/bounce.eml
Each send carries an X-Newsletter-Token header; the task correlates the bounce back
to its NewsletterSendRecord (falling back to the Final-Recipient address), marks the
record Bounced with the diagnostic reason, and globally suppresses the
subscriber. processRaw(string $raw): bool is public so a future IMAP/POP poller can
reuse the parsing.
Requires a mail-forwarder rule and
proc_openon the host.
Preview
Newsletter issues use Silverstripe's native CMSPreviewable support inside
NewsletterAdmin, so the preview appears in the standard CMS preview pane rather than
a custom floating panel. The built-in Silverstripe preview controls, including device
switching, remain available.
Preview rendering is handled by two ModelAdmin actions:
cmsPreview/<issueID>renders the current persisted issue state.cmsPreviewUnsaved/<issueID>accepts dirty Elemental block form data, applies it to the in-memory block instance only, and renders the iframe without writing the block or issue.
Preview as a subscriber
A toolbar at the top of the preview lets you render the issue as a real subscriber — like
MailChimp's "Preview as subscriber". cmsPreview accepts ?previewSubscriber=random (or a
subscriber ID), draws from the issue's recipients (falling back to any active subscriber), and renders
through renderEmail($issue, $subscriber) so {{ … }} merge fields are resolved for that person
(tracking pixels/links are omitted in preview). The toolbar's Another /
No personalisation links simply reload the iframe — no parent-frame scripting.
client/dist/newsletter-preview.js only coordinates the native preview:
- It watches Elemental block dirty/status changes, including editor text changes before the block is saved as draft or published.
- It posts the dirty block payload once per changed payload, preventing repeat
cmsPreviewloops after the first edit. - It injects the returned HTML into the native preview iframe and shows the translated "Contains unsaved changes" banner when the iframe represents unsaved block data.
- Once the block is saved and the dirty state clears, it refreshes the normal native preview URL.
Sent issues render from the locked SentHTML snapshot when available, so the preview
and public view-online pages reflect the sent content rather than later edits.
Routes
| Route | Purpose |
|---|---|
newsletter/view/<token> |
Public "view online" page for an issue; uses the sent snapshot when present. |
newsletter/viewrecord/<token> |
Recipient-specific "view online" page for a send record; resolves merge tags for that subscriber. |
newsletter/unsubscribe/<token> |
One-click unsubscribe (per-subscriber token). |
newsletter/open/<token>.png |
Open-tracking pixel. |
newsletter/click/<token>?u=<url> |
Click redirector. |
newsletter/preview/<issueID> |
Legacy/admin preview route; native CMS preview uses NewsletterAdmin cmsPreview actions. |
Layout
src/
Model/ NewsletterIssue, NewsletterAudience, NewsletterSubscriber,
NewsletterBrand, NewsletterSendRecord, NewsletterPermissions
Elements/ NewsletterBlockElemental (base) + concrete blocks, ScaledImageTrait
Service/ NewsletterRenderService, NewsletterSubscriptionManager, NewsletterSender
Job/ NewsletterSendJob
Task/ NewsletterSendTask, NewsletterAudienceRefreshTask, NewsletterBounceTask
Control/ NewsletterController
Admin/ NewsletterAdmin
Email/ MailHelper
Source/ AudienceSourceProvider
templates/MSpaceMedia/Newsletter/Email/ Wrapper.ss + Blocks/*.ss
client/dist/ newsletter-preview.js
lang/ Translation strings
Out of scope (v0.1)
- Bounce capture via IMAP/POP polling or ESP webhooks (only piped-DSN handling so far).
- A/B testing, scheduled-future sends beyond a queued start, full open/click drill-down dashboards.
- Per-recipient preview selection inside the CMS; recipient-specific output is rendered
by
newsletter/viewrecord/<token>after a send record exists.