Extending
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.
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.
Header logo
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
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>"}}'
| Field | Meaning |
|---|---|
messageId | The id the provider returned when the e-mail was sent (the send's providerMessageId). Required. |
type | queued, sent, delivered, opened, clicked, bounced, spam, failed or unsubscribed. Required. |
at | When it happened (ISO date); now by default. |
details.reason | The provider's words (550 5.1.1 …), shown in the log. |
details.bounceType | soft/transient/temporary or hard/permanent; without it the SMTP class of reason decides. A permanent bounce suppresses the address. |
details.eventId | The provider's event id: a retried webhook with the same id is ignored. |
details.url, details.userAgent | Shown 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:
| Status | reason | Why |
|---|---|---|
403 | missing_secret | No x-mailing-webhook-secret header. |
403 | bad_secret | The header does not match the workspace's secret, or the workspace has no mailing settings yet (any mailing page or send creates them). |
400 | invalid_event | The 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.
| Node | Id | Purpose |
|---|---|---|
| Action | mailing.send-template | Renders a live template and sends it. Inputs tenantId, slug, to (required), locale, variables, source. |
| Trigger | mailing.email-event | Fires on provider events. Config types filters the event types; empty fires on every one. |
Permissions and realtime
| Id | Kind | What it gates |
|---|---|---|
mailing.send | Permission (depends on the Sends page) | Every route that reaches recipients: test send, real send, send again |
mailing:sends | Realtime topic | Published 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.