[data-reveal]{opacity:1!important;transform:none!important}
Extending
Mailing

Extending

Send templates from code, ship templates as code, import past sends, report provider events, relay a provider webhook, and plug Mailing into automations.

Other modules talk to Mailing through the separately published @antelopejs/interface-dms-mailing interface. Declare it as a dependency: it wires your module to whichever module implements it.

Terminal
pnpm add @antelopejs/interface-dms-mailing

Send from code

Create and publish order-confirmation in the target workspace first. Its variables must include order.total; pass any other required variables your template declares. Use an authorized tenant ID and an inbox you control while developing. This example performs a real send, not a preview.

import {
  SendTemplate,
} from "@antelopejs/interface-dms-mailing";

// Render a live template and send it.
const result = await SendTemplate("order-confirmation", {
  tenantId,
  to: [{ email: "[email protected]", name: "Sofie" }],
  locale: "fr",
  variables: { order: { total: "48.00" } },
  source: "checkout",
});

const outcomes = result.results ?? [result];

Open Mailing › Sends to see the recipient, template version and locale used. outcomes contains one result per recipient; sent means the provider accepted the email, not that it reached the inbox. Report later provider events separately through the webhook contract or RecordEmailEvent.

Inspect result.results before retrying individual recipients: a multi-recipient call reports the worst status and first error, while its top-level IDs still belong to the first recipient. A recipient on the suppression list receives nothing and must not be retried. The consumer reference defines parameters, aggregation, failure handling, provider events and template types. Use content to preview an alternate body for an existing template and isTest: true to keep test sends out of production figures.

Ship templates as code

A project, or a demo, can ship its transactional templates with its code instead of asking someone to build them in the dashboard. DeclareTemplate creates the template under the slug you choose, publishes it as v1 so SendTemplate can use it at once, and on every later boot does nothing more than it has to:

  • the same declaration as last time writes nothing;
  • a changed declaration is published as a new version, but only while the template is still live, with no draft, and holds exactly what the code last declared;
  • a template a user edited, unpublished or archived since, or one made in the dashboard under that slug, is left as it is, including its lifecycle state. An archived or unpublished template remains unavailable for real sends.

DeclareTemplateCategory adds the categories those templates name to the workspace settings, with the same rule: it never relabels or brings back a category a user changed or removed.

These new APIs require an explicit, non-blank tenantId. This application function receives an authorized workspace; it does not create content for an implicit default tenant. Passing DEFAULT_TENANT_ID explicitly is valid.

import {
  DeclareTemplate,
  DeclareTemplateCategory,
} from "@antelopejs/interface-dms-mailing";

export async function declareMailingContent(tenantId: string): Promise<void> {
  await DeclareTemplateCategory({ tenantId, id: "subscriptions", label: "Subscriptions", icon: "i-ph-repeat" });
  await DeclareTemplate({
    tenantId,
    slug: "subscription-renewed",
    name: "Subscription renewed",
    category: "subscriptions",
    content: {
      locales: {
        en: {
          subject: "Your {{plan.name}} plan is renewed",
          preheader: "",
          blocks: [
            {
              id: "intro",
              type: "paragraph",
              text: "Hello {{customer.firstName}}, thank you for staying with us.",
              align: "left",
              visibleIf: null,
            },
          ],
        },
      },
    },
    variables: [
      { path: "customer.firstName", type: "string", required: true },
      { path: "plan.name", type: "string", required: true },
    ],
    testData: { customer: { firstName: "Sofie" }, plan: { name: "Pro" } },
  });
}

Both calls follow the dashboard's validation: invalid input rejects with a code and the field at fault without publishing content. A category removed by a user does not invalidate a declaration that only keeps an existing model. The calls wait for the module and the DMS database, which the DMS opens in its own start(). Never await them from construct() or a DATABASE_INITIALIZED handler. The consumer reference gives the exact rule and the outcomes.

For new SaaS workspaces, the application registers a durable created consumer with RegisterWorkspaceLifecycleConsumer from @antelopejs/interface-dms-saas/workspace-lifecycle, synchronously in its construct() before startup reconciliation, without awaiting declarations. start() is process-global: use it for explicit global work, not consumer registration or implicit workspace creation. The consumer runs after preparation/payment and receives at-least-once deliveries. Deduplicate by operationId and bind resources durably before returning a receipt. TENANT_BEING_PROVISIONED is the separate prepayment hook, not this durable transition. Mailing registers no SaaS consumer and adds no SaaS dependency.

Existing tenants need a separate deployment/backfill pass over an authorized inventory, gated by IsWorkspaceProvisioningCommitted(tenantId). Proceed only on true, failing closed on lookup errors. A non-blank tenant id proves neither authorization nor completed provisioning, and declarative idempotency does not make every consumer side effect exactly-once. See the application integration skeleton and existing-workspace guidance.

Template publication commits content and metadata together only if the template has not changed since it was read. A concurrent edit is preserved, and an interrupted initial declaration can resume if nobody edited its seed. All concurrent runtime writers must use this revision-aware implementation; direct writes to Mailing tables and mixed old/new writers bypass that guard.

A storage failure can reject after publication: the latest version remains readable from the committed template and its separate history row is repaired on a later declaration or publication. See the reference for retry semantics and the narrower, process-local serialization of category declarations.

Import past sends

SendTemplate stamps a send with the current time, and every figure of the Overview, the template statistics and the send log reads sends by that date. To bring in history from another provider, or to give a demo instance figures to show, ImportSends writes past sends with their own date and events:

import { ImportSends } from "@antelopejs/interface-dms-mailing";

// This function is called with an authorized, provisioned workspace id.
export async function importHistory(tenantId: string): Promise<void> {
  const sentAt = new Date(Date.now() - 3 * 86_400_000);
  await ImportSends({
    tenantId,
    sends: [
      {
        importId: "brevo-48213", // the old provider's id: importing again skips it
        templateSlug: "subscription-renewed",
        to: { email: "[email protected]", name: "Sofie" },
        sentAt,
        events: [
          { type: "delivered", at: new Date(sentAt.getTime() + 60_000) },
          { type: "opened", at: new Date(sentAt.getTime() + 3_600_000) },
        ],
      },
    ],
  });
}

It is an import, not a send: nothing is delivered, and no suppression, realtime update, automation or notification follows. The send's status and counters follow from its events as if the webhook had reported them. Imported sends deduplicate repeated events by details.eventId, or by equal type, timestamp and serialized details when no event ID is supplied. Retry a failed import with the same payload: batches can be partially committed, and an importId is not an update API. Import IDs are scoped to the tenant, so prefix them by provider when combining histories. Imported sends obey the log retention like any other, so keep a demo data set within the workspace's retention (90 days by default). See the consumer reference.

What a recipient receives

Every e-mail goes out as HTML and as a plain-text part generated from the same blocks (multipart/alternative), with a viewport meta.

The header logo is the DMS branding's light logo (branding.logo.default.light) at an absolute public URL (the DMS clientBaseUrl). The module leaves it out rather than showing it broken when:

  • The address is localhost, a private IP or .local.
  • The logo is an SVG, which Gmail and Outlook refuse.

The editor canvas draws the same header and footer.

Footer links are system variables. A footer whose unsubscribe or preferences label is filled (or a {{unsubscribeUrl}} / {{preferencesUrl}} token anywhere) declares unsubscribeUrl / preferencesUrl as variables of type url, required when the template is in the marketing category. The editor shows them in the Variables tab; the template drawer shows them in its contract and its "Call it from code" snippet.

Pass them in variables at send time:

await SendTemplate("newsletter-october", {
  tenantId,
  to: "[email protected]",
  variables: {
    unsubscribeUrl: `https://app.example.com/unsubscribe/${token}`,
    preferencesUrl: "https://app.example.com/preferences",
  },
});

A link whose URL is missing is dropped from the e-mail, and an empty label removes the link. For a marketing template, a missing required URL has these effects:

  • A manual real send is refused.
  • Every send is refused when "Missing variables" is set to refuse, except that a test send never waits for the URL.

The starters and the templates of the other categories get a footer with a legal line and no unsubscribe link.

Webhook contract

The module has one generic event format. Provider events are posted to

POST <clientBaseUrl>/api/mailing/events/<provider>/<tenantId>

the URL Settings › Provider & webhook shows, ready to copy. <provider> is a free label recorded with the event (the settings use the configured e-mail provider's name); <tenantId> names the workspace.

Every request carries the workspace's secret in the x-mailing-webhook-secret header (Settings › Provider & webhook; rotating it invalidates the old one at once). The body is JSON:

curl -X POST https://dms.example.com/api/mailing/events/postmark/<tenantId> \
  -H "Content-Type: application/json" \
  -H "x-mailing-webhook-secret: <secret>" \
  -d '{"messageId":"<provider message id>","type":"bounced","at":"2026-09-09T10:00:00.000Z","details":{"reason":"550 5.1.1 No such user","bounceType":"hard","eventId":"<provider event id>"}}'
FieldMeaning
messageIdThe id the provider returned when the e-mail was sent (the send's providerMessageId). Required.
typequeued, sent, delivered, opened, clicked, bounced, spam, failed or unsubscribed. Required.
atWhen it happened (ISO date); now by default.
details.reasonThe provider's words (550 5.1.1 …), shown in the log.
details.bounceTypesoft/transient/temporary or hard/permanent; without it the SMTP class of reason decides. A permanent bounce suppresses the address.
details.eventIdThe provider's event id: a retried webhook with the same id is ignored.
details.url, details.userAgentShown on the click and open steps.

The answer is 200 {"recorded": true}, or {"recorded": false} when no send carries the message id (or the event was already recorded).

When an event is recorded:

  • It is appended to the send's timeline.
  • The status only ever moves forward: a delivery after a provider failure wins over it.
  • A hard bounce, a spam report or an unsubscription puts the address on the suppression list.
  • Repeated problems on a template notify the tenant owners in the DMS bell.

A refused call gets a readable JSON body, for the relay's logs:

StatusreasonWhy
403missing_secretNo x-mailing-webhook-secret header.
403bad_secretThe header does not match the workspace's secret, or the workspace has no mailing settings yet (any mailing page or send creates them).
400invalid_eventThe secret is right but the body is not an event.
{ "error": "webhook_refused", "reason": "bad_secret", "message": "The x-mailing-webhook-secret header does not match …" }

The secret is checked before the body, so a caller without it learns nothing of the format. Settings › Provider & webhook › Webhook status shows the last event received (when, its type, the provider label, whether it matched a send) and the last call refused (when, and why). Send a test event posts a test event to the URL shown, with the stored secret, from the backend: it proves the URL is reachable from the server, the secret and the format, and touches no send (its message id is dms-mailing-webhook-test).

Relaying a provider

Providers post their own format (Postmark, SES through SNS, Mailgun, SendGrid, Resend…) and most cannot add a custom header. Put a small relay between them and the module: it checks the provider's own signature, translates each event and forwards it with the secret. A sketch for Postmark:

const TYPES: Record<string, string> = {
  Delivery: "delivered",
  Open: "opened",
  Click: "clicked",
  Bounce: "bounced",
  SpamComplaint: "spam",
  SubscriptionChange: "unsubscribed",
};

export async function relayPostmark(event: PostmarkEvent): Promise<void> {
  const type = TYPES[event.RecordType];
  if (!type) return;
  await fetch(MAILING_WEBHOOK_URL, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-mailing-webhook-secret": MAILING_WEBHOOK_SECRET,
    },
    body: JSON.stringify({
      messageId: event.MessageID,
      type,
      at: event.ReceivedAt ?? event.DeliveredAt ?? event.BouncedAt,
      details: {
        reason: event.Details ?? event.Description,
        bounceType: event.Type === "SoftBounce" ? "soft" : "hard",
        eventId: String(event.ID ?? ""),
        url: event.OriginalLink,
        userAgent: event.UserAgent,
      },
    }),
  });
}

A module of the same project can skip HTTP altogether and call RecordEmailEvent(tenantId, event) from the public interface. A provider or a relay posts through the dashboard's URL, as a public endpoint the module declares (AddFrontendModule({ publicEndpoints })), or straight to the API on apiBaseUrl when it is reachable: the path and the header are the same.

Automation nodes

Registered against the optional @antelopejs/interface-dms-automation. They are inert no-ops when no module implements it.

NodeIdPurpose
Actionmailing.send-templateRenders a live template and sends it. Inputs tenantId, slug, to (required), locale, variables, source.
Triggermailing.email-eventFires on provider events. Config types filters the event types; empty fires on every one.

Permissions and realtime

IdKindWhat it gates
mailing.sendPermission (depends on the Sends page)Every route that reaches recipients: test send, real send, send again
mailing:sendsRealtime topicPublished on every new send and event; the Overview and Sends pages subscribe to it

The module also contributes templates.json, template-versions.json, settings.json and suppressions.json to the workspace data export (RegisterTenantDataExportContributor).

Retention internals

The daily sweep retires sends only when their creation time, latest event time and latest ingested activity all precede the tenant's retention cutoff. Queued sends remain active. Retirement irreversibly closes a send to provider events; the sweep then removes its events and finally the send. A failed cleanup leaves the retirement marker available for another sweep to resume.

Concurrent sweeps require no shared cron lock. Send mutations use the database interface's Table.atomicMutation revision contract. Retirement rechecks the send's age and activity before comparing its revision; concurrent webhook activity advances that revision and invalidates the stale candidate. Known webhook conflicts reread and recompute counters, with a bounded retry limit. An unknown write outcome fails without retrying or reporting a deletion.

This requires the atomic-mutation interface and adapter implementations; generic filtered updates and deletes are not an alternative. The dependency releases are prerequisites for rollout. Every send is inserted with a unique identity and revision, stored in the declared revision column.

Each sweep inspects at most 20,000 send candidates per tenant and reports only actual send deletions. It also reconciles orphan events left by writers that crashed after inserting an event for an already-deleted send. That reconciliation scans the event table in keyset pages of 500 rows, with a parent lookup per distinct send in each page; its total work is not capped by the send limit. Event writes create the parent first and never reuse deleted send IDs. Neither retention path deletes templates or files. Disabled retention skips both passes.