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 (email for Email, sms for SMS — deliberately named sms, not phone), and
  • its body artifacts: an ordered map of artifact keys to the bundle fields that store them, with required/optional flags (Email: html required → field_body_html, text optional → field_body_text; SMS: text required).

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_upload at /campaign-grapesjs/asset/upload (the campaign_grapesjs submodule) answers {"data": [...]} for the GrapesJS Asset Manager, POST-only, protected by a _csrf_token baked into the URL server-side so the editor JS never handles tokens. A batch fails whole on the first rejected file.
  • campaign_mosaico.upload at /campaign-mosaico/upload (the campaign_mosaico submodule) 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 the X-CSRF-Token header. 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/image serves block placeholders (method=placeholder) and derivatives (resize, cover) of uploaded images. It keeps message-authoring permission (create campaign messages or edit 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 in public:// and are reachable directly anyway — the gate bounds compute cost, not confidentiality. Its security posture: src is confined to the shared asset directory (accepted as a public:// 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 of params may be the literal null, which the Mosaico dist sends for an unconstrained resize dimension) and capped at 2600 px per axis, and derivatives are cached on disk under public://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.