Architecture
The contracts on this page are what follow-up issues (delivery, tracking, automations, engagement) code against. They were settled in the plan attached to #3615583.
Channel-neutral core, channel-specific bundles
The campaign_message base schema carries only channel-neutral fields: label,
campaign backreference, "Sent to" display-name pattern, composition plugin id
and configuration, and the web slug. Everything channel-specific — subject,
sender identity, body fields — is a bundle field on a campaign_message_type,
and each message type binds to exactly one Channel plugin.
A Channel plugin declares:
- the contact-point type it addresses (
emailfor Email,smsfor SMS — deliberately namedsms, notphone), and - its body artifacts: an ordered map of artifact keys to the bundle fields
that store them, with required/optional flags (Email:
htmlrequired →field_body_html,textoptional →field_body_text; SMS:textrequired).
Channel plugins carry no delivery logic. Adding a channel (postal, push, …) means a channel plugin plus a message bundle with its fields — no change to the base schema.
Composition: configuration is truth, the render is stored
A Composition plugin owns the authoring UI for a message body. Its
configuration lives on the message (revisionable); on save, the message runs
render() and writes each returned artifact into the bundle field named by
the channel's artifact map, and the channel finalizes the set (the Email
channel derives plaintext from HTML when absent). Consequences:
- revisions capture the design (plugin configuration) and the exact rendered output;
- sending reads stored fields and never needs the composition plugin at send time;
- a composition that cannot render fails the save loudly — never a silently empty body.
The two GrapesJS plugins (the optional campaign_grapesjs submodule) sit on
this contract, and the MJML one extends it with client-side compilation: the
MJML source and its browser-compiled HTML are stored together, validated as a
both-or-neither pair. There is deliberately no server-side MJML dependency and
therefore no headless re-render pathway; the stored HTML is regenerated only
through the editor.
The Mosaico plugin (the optional campaign_mosaico submodule) follows the
same pattern with a triple: metadata (Mosaico's template reference and
editing state), content (the block model — the re-editability payload) and
html (the compiled export). All three are produced together by the client at
save time, so the server enforces an all-or-nothing consistency invariant: a
submission or stored configuration carrying some but not all of the three is
rejected, and render() throws on an inconsistent configuration rather than
rendering silently.
Unlike the GrapesJS editors, Mosaico owns the viewport instead of embedding in
the form. The subform renders a launcher button plus three hidden fields; the
launcher opens a maximized dialog iframe onto a stateless editor-shell route
(/campaign-mosaico/editor — no entity load, no query data, one cheap
document for every message form). The model travels between form and shell
over origin-checked postMessage (shell announces readiness, form sends the
stored model, shell sends the triple back on save), the launcher fills the
hidden fields, and persistence happens through the normal message form — the
shell itself never saves anything.
The EmailBuilder.js editor (the campaign_emailbuilder submodule) follows the
same client-side pattern: the design JSON document is the source of truth in
the plugin configuration, and the browser renders it to HTML at save time via
the library's official renderToStaticMarkup. Document and HTML are stored
together, validated as a both-or-neither pair, with no server-side Node
dependency; the browser-produced HTML remains a declared sanitization point
(Xss::filterAdmin on the canonical page). The editor bundle is built from
the in-repo wrapper/ project and published as drupal-campaign-emailbuilder
for site installation under libraries/.
The campaign_easy_email submodule's block-based easy-email editor follows
the same stored-pair contract: document JSON rendered to HTML in the browser
at save time.
One asset surface, two protocols
The base module owns the surface; each editor submodule owns its protocol
adapter. Uploads are performed by a single service, campaign.asset_uploader
in the base module, which owns the destination (public://campaign/images),
the validator set (extension allow-list, 5 MB cap, decodable-image proof,
4000x4000 ceiling) and the temporary-file lifecycle — plus the restricted
upload campaign assets permission every adapter gates on, and
AssetUsageTracker scanning every revision of every message for references to
that directory. Being able to author a message does not imply being able to
place files on the server, and an editor submodule must not widen that.
The base module ships no upload route of its own. Two thin controllers sit over the service, one per editor submodule, because the editors speak genuinely different wire formats — and each keeps its own CSRF mechanism:
campaign_grapesjs.asset_uploadat/campaign-grapesjs/asset/upload(thecampaign_grapesjssubmodule) answers{"data": [...]}for the GrapesJS Asset Manager, POST-only, protected by a_csrf_tokenbaked into the URL server-side so the editor JS never handles tokens. A batch fails whole on the first rejected file.campaign_mosaico.uploadat/campaign-mosaico/upload(thecampaign_mosaicosubmodule) answers the blueimp jQuery-File-Upload{"files": [...]}envelope Mosaico's uploader and gallery consume — POST stores, GET lists — and requires the session CSRF token in theX-CSRF-Tokenheader. The protocol has no request-level error shape, so it reports every file, rejections included, inside a successful envelope. Its gallery lists the whole shared directory, so images uploaded from a GrapesJS editor appear there too.
File usage is owned solely by AssetUsageTracker in the base module, for
every composition plugin. It serializes the composition configuration of
all revisions of a message and scans it for the asset path, so a file
referenced by any revision stays permanent and carries exactly one usage row;
a file referenced by none loses its row on the next recompute. Editing an
image out of a message therefore does not strand the revisions that still
reference it. Because the scan is plugin-agnostic, Mosaico's metadata,
content and html are covered without the submodule tracking anything
itself.
Mosaico's image endpoint
Mosaico additionally requires a server-side image backend, on top of the
upload adapter every editor submodule ships: installing campaign_mosaico is
what opts a site into this surface. It has no counterpart in
campaign_grapesjs.
/campaign-mosaico/imageserves block placeholders (method=placeholder) and derivatives (resize,cover) of uploaded images. It keeps message-authoring permission (create campaign messagesoredit campaign messages) rather than the upload permission: an author who may not upload still needs placeholders and resizes for images already in a template, and the files it serves live inpublic://and are reachable directly anyway — the gate bounds compute cost, not confidentiality. Its security posture:srcis confined to the shared asset directory (accepted as apublic://URI or a same-origin URL, with realpath containment as the traversal guard; never fetched over HTTP), methods are whitelisted, dimensions are strictly parsed (either side ofparamsmay be the literalnull, which the Mosaico dist sends for an unconstrained resize dimension) and capped at 2600 px per axis, and derivatives are cached on disk underpublic://campaign/derivatives— their own tree, deliberately not inside the asset directory, which belongs to the base module and is both listed by the gallery and scanned by the usage tracker. Requests may carry a core-itok-style derivative token over the (src,method,params) triple: a present token must validate, but authorized sessions may omit it — the Mosaico client builds arbitrary-dimension URLs and cannot mint HMACs, and the permission gate plus the caps and confinement already bound the per-request cost — so the token exists to harden any future surface that exposes the endpoint beyond authoring sessions.
Recipients are value objects, not addresses
A Recipient plugin resolves to recipient value objects exposing named
contact points (email, sms, …) and named data fields (for the "Sent
to" pattern and future personalization). Each plugin declares which
contact-point types and data fields it can supply. Campaign validation uses
those declarations:
- every message's channel must be addressable by at least one include set;
- every
[placeholder]in a "Sent to" pattern must be a data field every include set provides.
A bare email list therefore cannot satisfy [first] [last] — by design.
The audience and the snapshot-at-send contract
A campaign composes recipient sources through two separate multi-value fields — include sets and exclude sets. The audience resolver computes:
union(includes) − union(excludes)
de-duplicated per canonicalized contact point (emails lowercased and trimmed;
sms numbers stripped of separators with a leading + preserved). A recipient
is excluded when any of its contact points matches an exclude-set
recipient's contact point of the same type — erring toward not messaging
anyone who opted out.
Snapshot contract: the audience is resolved once at send time, and the resolved snapshot is what delivery uses and what history refers to. This module ships resolution only; persisting the snapshot is explicitly the delivery issue's scope.
Direct sends are campaigns
Every send is a campaign — including a one-off message to one person. The
direct path (decided in
#3615621) is
convention plus surfaces, not schema: a shipped direct campaign type, the
direct_recipient source holding exactly one channel-agnostic recipient,
and two thin entry points (the quick-send form and the
campaign.direct_send_builder service, the canonical programmatic API)
that assemble a normal campaign. Nothing about the audience snapshot,
lifecycle, lock or delivery contract is special-cased, so future delivery
and engagement treat direct and bulk sends identically.
Scheduling context
SchedulingInterface::resolveSendTime() takes a context of three parts: the
campaign, an optional specific message, and an optional prior-step completion
timestamp. A one-time campaign send is simply a context with no message and no
prior step. Multi-step automations later get per-message and relative timing
by populating the other two — the interface does not change.
Lifecycle and the sent lock
campaign.status moves forward-only: draft → scheduled → sending → sent, with
no transition out of sent (enforced by an entity constraint; the delivery
issue flips the states through the same constraint). Once the stored
status is sent, the campaign and its messages lock: access control forbids
update/delete, and lock constraints reject modifying saves. Revisions plus the
lock make history provably match what was transmitted.
The campaign→message relation is bidirectional with the campaign side
authoritative: the ordered messages field deltas are the send sequence, and
postSave() syncs each message's campaign backreference (last writer wins).
Web URL contract
The message permalink is /campaign/message/{slug} where the slug is stored
on the message and generated as {id}-{transliterated-label}. The {id}-
prefix exists only to make collisions structurally impossible — it is not
a lookup key. The route resolves by exact full-slug match; a correct id with a
wrong tail is a 404, never a redirect, so messages cannot be discovered by
incrementing ids. Editing a slug invalidates previously shared URLs (no
redirect history is kept — an accepted trade-off).
Stored body HTML is treated as privileged user input (the campaign_grapesjs
and campaign_mosaico editors submit it from the browser): the canonical page
renders it through Xss::filterAdmin(), and composing is gated behind
dedicated permissions.