Mailing interface
@antelopejs/interface-dms-mailing exposes five backend functions and the template types below: two to send and to report events, two to ship templates and categories as code, and one to import past sends. Load @antelopejs/dms-mailing and an @antelopejs/interface-email provider before calling them. Import from the interface package, not the runtime module's services.
All five functions take an explicit tenantId. They are trusted backend calls, not permission-checked HTTP endpoints: authenticate and authorize the tenant in the calling module. A missing implementation or a storage failure can reject the promise.
Consume per-recipient results
Publish an order-confirmation template in your authorized tenant, with
order.total as a variable. This example performs a real send; use an inbox you
control and supply any additional variables required by your template.
import { SendTemplate } from "@antelopejs/interface-dms-mailing";
export async function sendOrderConfirmation(tenantId: string, recipients: string[]) {
const result = await SendTemplate("order-confirmation", {
tenantId,
to: recipients,
variables: { order: { total: "48.00" } },
source: "checkout",
});
return result.results ?? [result];
}
The returned array lets you inspect each recipient separately. For example, a
two-recipient call can return sent for one address and suppressed for the
other. Do not resend both: the first email may already be on its way, and the
suppressed recipient must not be retried. The Sends page records the template
version and locale; provider events later add delivery information.
SendTemplate
import type { EmailAddress } from "@antelopejs/interface-email";
import type { SendStatus, TemplateContent } from "@antelopejs/interface-dms-mailing";
interface SendTemplateParams {
tenantId: string;
to: EmailAddress | EmailAddress[];
locale?: string;
variables?: Record<string, unknown>;
content?: TemplateContent;
source?: string;
isTest?: boolean;
}
interface SendTemplateResult {
sendId: string;
status: SendStatus;
messageId?: string;
error?: string;
results?: SendTemplateResult[];
}
declare function SendTemplate(
slug: string,
params: SendTemplateParams,
): Promise<SendTemplateResult>;
to accepts an address string or { email: string, name?: string }, or an array of either. The slug resolves within the specified tenant. The template must exist even when content overrides its body; a non-test send requires a live template. content does not publish or save the override. The locale falls back through the workspace's fallback locale and available content. source labels the send log.
The module renders HTML and plain text, uses batch delivery when the provider supports it, and attempts to log one row per recipient. isTest marks the send as a test, prefixes its subject, permits a non-live template, and excludes it from production metrics. It does not bypass the suppression list.
Results and failures
For multiple recipients, the top-level result describes the whole call, not just the first recipient:
sendIdandmessageIdbelong to the first recipient.statusis the worst outcome: from best to worst,clicked,opened,delivered,sent,queued,unsubscribed,suppressed,spam,bounced,failed.erroris the first nonempty error in recipient order, not necessarily the error of the recipient with the worst status.resultscontains one result per recipient in input order. Those entries do not contain nestedresults. The type makes this field optional; the current implementation includes it even for a single recipient.
A suppressed address receives no email. Its result has status: "suppressed" and an error key beginning with $dms_mailing.errors.suppressed., followed by hard_bounce, complaint, unsubscribe, or manual. Do not retry suppressed recipients.
Provider failures become failed recipient results. If recording a send fails, that result has sendId: "", status: "failed", and an error, but may still carry a provider messageId: delivery may already have happened. Do not blindly retry the whole call or infer that a failed result means no email left the provider.
Validation failures reject instead of returning recipient results. The runtime's errors carry codes no_recipient, template_not_found, template_not_live, invalid_content, or missing_variables; the error class is not an interface export. Missing-variable refusal follows the workspace setting. Marketing footer URLs are required system variables when the template uses them; test sends do not require those system URLs. Storage or downstream notification failures can also reject after side effects have started, so this call is not transactional or idempotent.
RecordEmailEvent
type SendEventType =
| "queued" | "sent" | "delivered" | "opened" | "clicked"
| "bounced" | "spam" | "failed" | "unsubscribed";
type SendStatus = SendEventType | "suppressed";
interface EmailEvent {
provider: string;
messageId: string;
type: SendEventType;
at?: Date;
details?: Record<string, unknown>;
}
declare function RecordEmailEvent(
tenantId: string,
event: EmailEvent,
): Promise<boolean>;
messageId is the provider's message ID, not the mailing sendId. Matching uses the tenant and message ID; provider is not an authorization boundary or an additional matching key. at defaults to now and is a Date in this backend interface, unlike the ISO string in the HTTP webhook.
The call returns true when it records the event. It returns false for an unmatched message, a duplicate event, or a send that is retiring or no longer accepts events. Supply a stable details.eventId for provider retries. Without it, deduplication requires the same explicit at, event type, and serialized details; omitting both event ID and timestamp does not deduplicate retries.
Accepted events update the send timeline, status and engagement counters, publish realtime and automation events, and can notify tenant owners of template problems. A hard bounce, spam complaint, or unsubscribe suppresses the recipient. A test send suppresses only on a hard bounce; soft bounces never suppress. Suppression-write failures are logged without rejecting the recorded event. Repeated activity conflicts, storage failures, or downstream publication failures can reject; a rejection does not guarantee that no event was stored.
details.reason supplies provider error text. details.bounceType distinguishes soft/transient/temporary from hard/permanent; otherwise the SMTP class in the reason decides. details.url and details.userAgent annotate engagement events. Authenticate a provider's webhook before translating it to this trusted call. The runtime webhook guide defines HTTP authentication and response bodies.
Ship templates as code
A project can declare its transactional templates from backend code instead of creating them in the dashboard. A newly created template claims its slug, is published as v1 with history, and is ready for SendTemplate when the call resolves. Existing templates remain subject to the ownership and lifecycle rules below: unchanged and kept do not guarantee that an archived or unpublished template is sendable.
DeclareTemplate, DeclareTemplateCategory and ImportSends require an explicit tenantId, in TypeScript and at runtime. Omission, an empty string or whitespace-only input rejects before any write, with invalid_template, invalid_category or invalid_import respectively and tenantId in the message. There is no default fallback. Passing DEFAULT_TENANT_ID explicitly remains valid. This requirement identifies the target; it proves neither authorization nor completed provisioning. Trusted application code must enforce both. Existing SendTemplate and RecordEmailEvent contracts are unchanged.
DeclareTemplate
import type {
TemplateContent,
VariableDefinition,
} from "@antelopejs/interface-dms-mailing";
interface TemplateDeclaration {
tenantId: string; // explicit authorized workspace, non-blank
slug: string;
name: string;
category?: string; // a category id of the workspace settings
content: TemplateContent;
variables?: VariableDefinition[];
testData?: Record<string, unknown>;
author?: string; // shown as "updated by" / "published by"; "system" when absent
}
type DeclarationOutcome = "created" | "updated" | "unchanged" | "kept";
interface DeclareTemplateResult {
templateId: string;
slug: string;
outcome: DeclarationOutcome;
version: number; // last published version, or 0; not a live-status guarantee
}
declare function DeclareTemplate(
declaration: TemplateDeclaration,
): Promise<DeclareTemplateResult>;
The slug is the key the caller chooses. Calling DeclareTemplate on every boot or hot reload is the intended use, and it never creates a second template. The module stores a fingerprint of the declaration it last applied (name, category, content, variables and test data) on the template and decides as follows:
| The template under that slug | Outcome | What is written |
|---|---|---|
| does not exist | created | the template, published as v1 |
| was declared with the same declaration | unchanged | no content or lifecycle change, whatever a user did since; interrupted history may be repaired |
was declared before, is still live with no draft and holds exactly what the last declaration wrote | updated | the new declaration, published (a new version when content or variables changed) |
| was edited, holds a draft, was unpublished or archived since, or was made in the dashboard | kept | no content or lifecycle change; interrupted history may be repaired |
So a user's work always wins: once someone edits a declared template, the code stops refreshing it until the template holds exactly what the code last declared again. Archive a declared template to take it out of use for good; a declared template deleted in the dashboard is created again by the next call, since the code that declares it is the code that sends it.
Publication compares the template's semantic revision and commits content, metadata and the declaration fingerprint together. An intervening dashboard edit causes a reread and ownership check instead of an overwrite; repeated contention rejects with declaration_conflict. A concurrent caller can finish an unmodified initial declaration left by an interrupted creator, but cannot publish over a user-edited seed.
The declaration follows the dashboard's rules: the slug pattern and length of a new template, a non-blank name, content and variable declarations valid for the editor, at least one locale and a subject in every locale (what Publish requires). A creation or refresh also requires a category the workspace settings declare (or none); unchanged and kept do not fail merely because a user removed the original category. Validation errors use invalid_template, unknown_category, nothing_to_publish or empty_subject; their messages identify the invalid input, and they do not publish a template.
A storage or attention-refresh failure can reject after publication commits. The committed template retains the complete latest version so history and numbered-version previews remain readable if the history insert fails; a later declaration or publication repairs that row before replacing the snapshot. Retry the same declaration after resolving the storage problem. An indeterminate database mutation rejects without automatic retry or rollback: rejection is not proof that nothing changed.
DeclareTemplateCategory
interface TemplateCategoryDeclaration {
tenantId: string;
id: string; // lowercase letters, digits and dashes
label: string;
icon: string; // a Phosphor icon, such as "i-ph-repeat"
}
interface DeclareTemplateCategoryResult {
id: string;
outcome: "created" | "updated" | "unchanged" | "kept";
}
declare function DeclareTemplateCategory(
declaration: TemplateCategoryDeclaration,
): Promise<DeclareTemplateCategoryResult>;
Adds a category to the workspace settings (created), relabels one still as last declared (updated), or does nothing (unchanged). A category a user relabelled or removed since, and one that existed before any declaration (the defaults orders, account, billing, marketing and forms among them), is left as it is (kept): a removed category is never added back. A label another category already has rejects with invalid_category. Declarations of one workspace run one after the other within one process; this queue is not a cross-process lock and does not serialize dashboard settings writes. Coordinate concurrent category/settings writers externally. Declare a category before templates that name it.
When to call
The calls wait for the module and for the DMS database, so start order never makes them fail: a call made before @antelopejs/dms-mailing is constructed is queued by the interface, and a call made before the DMS opened its database waits for it. The DMS opens it in its own start(), after every module's construct(), so:
- in
construct(), register lifecycle consumers synchronously, without calling or awaiting declarations: this ensures registration precedes startup reconciliation; - in
start(), perform explicitly scoped global work, not consumer registration that can race reconciliation. Aconstruct()awaiting declarations would wait for database startup, which only runs after everyconstruct()returned; - in a
Hook.DATABASE_INITIALIZEDhandler, do not await them either: the hook's handlers run one after the other.
start() runs for the process, not for each SaaS workspace. Database readiness does not mean a tenant has completed provisioning. The following application function receives its authorized target from its caller; it is not an implicit default-tenant startup initializer.
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" });
const result = 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" } },
author: "Release script",
});
if (result.outcome === "kept") {
// Preserve the user's content and lifecycle; it may be archived.
}
}
Initialize new SaaS workspaces
For an application using dms-saas, register a durable created consumer through RegisterWorkspaceLifecycleConsumer from @antelopejs/interface-dms-saas/workspace-lifecycle. Register it synchronously in the application's construct(), before startup reconciliation begins; the async consumer executes later. The created transition follows workspace preparation and payment; delivery is at least once. TENANT_BEING_PROVISIONED is a different, prepayment hook: an error aborts creation, with no automatic replay or compensation of consumer effects.
The following is an integration skeleton, not a standalone persistence implementation. The application must implement the durable operation ledger and resource binding described in the comments before returning a receipt. Mailing neither depends on dms-saas nor registers this consumer.
import { RegisterWorkspaceLifecycleConsumer } from "@antelopejs/interface-dms-saas/workspace-lifecycle";
import { declareMailingContent } from "./mailing-content"; // application function above
export function construct(): void {
RegisterWorkspaceLifecycleConsumer({
name: "application.mailing",
transitions: ["created"],
async consume({ tenantId, operationId }) {
// Application responsibility: deduplicate concurrent/replayed operationId
// durably, returning the recorded receipt for completed operations.
await declareMailingContent(tenantId);
// Persist the resource bindings and completed operation before returning
// its durable receiptId. A crash here must be recoverable on redelivery.
// Return { receiptId, ... } from that application-owned durable record.
throw new Error("Implement durable operation binding and receipt storage");
},
});
}
Use operationId as the durable deduplication key. Idempotent declarations make their own retries safe; they do not make arbitrary consumer side effects exactly-once. Never acknowledge success with a fabricated receipt or before resources are durably associated with the operation. Do not await declarations from construct() while database startup is still blocked; consumer registration and consumer execution are distinct.
Update existing SaaS workspaces
A new deployment does not emit created for every existing tenant. Apply code updates or backfills through an application-owned, authorized tenant inventory. For each candidate, check IsWorkspaceProvisioningCommitted(tenantId) from the same workspace-lifecycle entry point before declaring or importing. Proceed only on true; skip an uncommitted tenant, and treat lookup/inventory errors as failures, never permission to proceed. Preserve progress for retries and coordinate category/settings writers. This is separate from initializing a newly created workspace, and it must never silently fall back to the default tenant.
ImportSends
import type { EmailAddress } from "@antelopejs/interface-email";
import type { SendEventType } from "@antelopejs/interface-dms-mailing";
interface ImportedSendEvent {
type: SendEventType;
at: Date;
details?: Record<string, unknown>;
}
interface ImportedSend {
importId: string; // stable key chosen by the caller
templateSlug: string;
to: EmailAddress;
sentAt: Date;
locale?: string; // the workspace fallback locale when absent
variables?: Record<string, unknown>;
provider?: string;
providerMessageId?: string;
source?: string; // "import" when absent
isTest?: boolean;
events?: ImportedSendEvent[];
}
interface SendImport {
tenantId: string;
sends: ImportedSend[];
}
interface ImportSendsResult {
imported: number;
skipped: number;
sendIds: string[]; // one per entry, in the order given
}
declare function ImportSends(params: SendImport): Promise<ImportSendsResult>;
An import, not a send. SendTemplate stamps every send with the current time, and the Overview, the template statistics and the send log read sends by that date: history from another provider, or a demo data set, cannot be replayed through it. ImportSends writes past sends with their own sentAt and events instead. Nothing is delivered, and no suppression, realtime update, automation or owner notification follows: import the suppression list separately if the old provider kept one.
Each entry names a template of the workspace (its published version is recorded) and is keyed by its importId: an entry imported before is skipped untouched, so the call can run on every boot. The events can come in any order; the send's status, open and click counters, error and soft-bounce flag follow from them as if the webhook had reported them, and an entry without events is sent. An unknown template rejects the whole call with template_not_found before anything is written; an invalid entry rejects it with invalid_import, naming the field (sends.3.to). The log retention applies to imported sends like to any other: one older than the workspace's retention (90 days by default) goes at the next nightly run, so a demo data set should stay within it.
Events are sorted by at and deduplicated before storage and counting: details.eventId identifies provider retries; without it, the type, timestamp and serialized details must match. The earliest sorted occurrence is kept. Import IDs are scoped by tenant, not by provider: prefix them when importing more than one provider's identifier space.
Imports are not transactional batches. A storage failure can leave earlier entries committed or an entry's events written before its send row; retry with the same immutable payload, including event order for equal timestamps. Changing an interrupted entry's payload under the same importId is not supported. Retention removes the deduplication record with the send, so an old key can be imported again after purge. The API records the template's current published version, not an arbitrary version from the previous provider.
Template types
TemplateContent is { locales: Record<string, LocaleContent> }; each LocaleContent has subject: string, preheader: string, and blocks: Block[].
All blocks share BlockBase: id: string, the discriminant type: BlockType, and visibleIf: Condition | null. Block is the union of the following exported interfaces. All listed fields are required; TextAlign is "left" | "center" | "right".
Interface / type | Additional fields |
|---|---|
HeroBlock / hero | imageUrl: string, alt: string |
HeadingBlock / heading | text: string, align: TextAlign, size: number |
ParagraphBlock / paragraph | text: string, align: TextAlign |
CodeBlock / code | text: string |
ListBlock / list | source: string, labelPath: string, valuePath: string |
TotalBlock / total | label: string, value: string |
ButtonBlock / button | text: string, href: string, align: TextAlign |
IfBlock / if | condition: Condition, children: Block[], elseChildren: Block[] | null |
DividerBlock / divider | None |
FooterBlock / footer | text: string, unsubscribeLabel: string, preferencesLabel: string |
Condition has path: string, operator: ConditionOperator, and value: string. Operators are truthy, falsy, eq, ne, gt, and lt. VariableDefinition has path: string, type: VariableType, and required: boolean. Variable types are string, text, number, money, date, url, boolean, and array.
For template setup, footer variables and automation integration, use the extending guide. Installation, configuration and HTTP routes remain in the runtime guide.