Outgoing transactional mail, and only that: the invitation, the re-sent invitation, the password reset and, since 0.13.0, the password recovery a member asks for themselves. One backend per deployment. No newsletter, no inbound mail, no template editor.
Unset MAIL_PROVIDER is a complete configuration
No queue, no worker. Invite and reset routes return the raw token,
reveal-link answers 409 MAIL_NOT_CONFIGURED, and the public
POST /api/auth/password-recovery is a 404 — the sign-in page’s “Forgot it?”
says to ask an admin instead. A fresh clone and a freshly
scaffolded app run this way.
Install
npm install @orthacms/mail-server @orthacms/mail-provider-smtpcreate-orthacms-app --mail mail-smtp installs and wires both. Every generated
app also gets @orthacms/mail-domain and the two offline adapters,
console and testkit.
There is no admin package.
Configure
# .env
# "smtp" (or "console" in the reference server). Unset = send nothing.
MAIL_PROVIDER=smtp
# required once MAIL_PROVIDER is set: every link is built from it
APP_URL=https://cms.example.com
# required once MAIL_PROVIDER is set
MAIL_FROM="Ortha CMS <no-reply@example.com>"
MAIL_REPLY_TO=support@example.com
# what the copy calls the product; default "Ortha CMS"
MAIL_PRODUCT_NAME=Acme CMS
# debugging only: also return the raw token from the API
MAIL_REVEAL_LINKS=false
# worker poll interval; 0 = queue here, send from another process
MAIL_DELIVERY_INTERVAL_MS=2000
MAIL_MAX_ATTEMPTS=5
# SMTP transport — see the SMTP page
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=...
SMTP_REQUIRE_TLS=trueAPP_URLandMAIL_FROMare checked withrequireEnv: missing, the boot fails with a message naming the variable.- Any other
MAIL_PROVIDERvalue fails the boot. A generated app acceptssmtponly.
The reference server, apps/server/config/mail.ts, trimmed:
import type { MailPluginConfig } from '@orthacms/mail-server';
import type { SmtpMailProviderOptions } from '@orthacms/mail-provider-smtp';
export interface OrthaCmsMailConfig extends MailPluginConfig {
backend: 'smtp' | 'console';
smtp?: SmtpMailProviderOptions;
}
export function mailConfig(): OrthaCmsMailConfig | undefined {
const selected = backend(); // reads MAIL_PROVIDER; undefined when unset
if (!selected) return undefined;
return defined({
backend: selected,
appUrl: requireEnv('APP_URL', '…'),
from: requireEnv('MAIL_FROM', '…'),
replyTo: readEnv('MAIL_REPLY_TO'),
productName: readEnv('MAIL_PRODUCT_NAME'),
revealLinks: readFlag('MAIL_REVEAL_LINKS', false),
deliveryIntervalMs: readOptionalPositiveInt('MAIL_DELIVERY_INTERVAL_MS'),
maxAttempts: readOptionalPositiveInt('MAIL_MAX_ATTEMPTS'),
smtp: when(selected === 'smtp', () => smtpTransport()) // SMTP_* — see the SMTP page
});
}Plugin options
MailPluginConfig. Backend credentials are not here; they belong to the
provider factory.
| Option | Default | Notes |
|---|---|---|
appUrl | required | Absolute URL links are built from |
from | required | Bare address or Name <address> |
replyTo | unset | |
productName | Ortha CMS | |
revealLinks | false | Returns the raw token as well as sending |
deliveryIntervalMs | 2000 | 0 = never send from this process |
batchSize | 20 | Messages claimed per tick |
maxAttempts | 5 | |
claimLeaseMs | 60000 | Must exceed one hand-off |
sweepIntervalMs | 300000 | Expired rows are swept this often |
templates | shipped copy | Partial map, by kind |
Register
// apps/server/src/plugins.ts
import { MailServerPlugin } from '@orthacms/mail-server';
import { createSmtpMailProvider } from '@orthacms/mail-provider-smtp';
import { createConsoleMailProvider } from '@orthacms/mail-provider-console';
import type { MailProvider } from '@orthacms/mail-domain';
export function mailProvider(config: OrthaCmsConfig): MailProvider | null {
const mail = config.plugins.mail;
if (!mail) return null;
if (mail.backend === 'console') return createConsoleMailProvider();
if (!mail.smtp) throw new Error('MAIL_PROVIDER=smtp but no SMTP settings — set SMTP_HOST.');
return createSmtpMailProvider(mail.smtp);
}
function mailPlugin(config: OrthaCmsConfig): ServerPlugin[] {
const provider = mailProvider(config);
const mail = config.plugins.mail;
return provider && mail ? [MailServerPlugin({ provider, config: mail })] : [];
}
// in the plugin list: after DatabasePlugin, before UsersPlugin (users queues through it)
[
// …DatabasePlugin, IdentityPlugin, WorkspacesPlugin, ActivityPlugin
...mailPlugin(config),
UsersPlugin()
// …
];It owns one table, mail_deliveries, with migrations under
__drizzle_migrations_mail.
Verify
The boot log says which backend is live:
LOG [MailProviderCheck] Mail is enabled through the "smtp" provider, sending as Ortha CMS <no-reply@example.com>.A rejected SMTP login refuses the boot:
Error: The mail provider "smtp" refused the configured sender "Ortha CMS <no-reply@example.com>": SMTP relay smtp.example.com:587 refused the connection — EAUTH: 535 …Then invite someone. With a backend, the response has no inviteToken:
curl -s -b cookies.txt http://localhost:3000/api/users/invites \
-H 'Content-Type: application/json' \
-d '{"email":"grace@example.com","role":"contributor","name":"Grace Hopper"}' {
"id": "7d0c…",
"email": "grace@example.com",
"name": "Grace Hopper",
"role": { "id": "…", "key": "contributor", "name": "Contributor" },
"status": "pending",
"createdAt": "2026-09-30T09:14:00.000Z",
"isLastAdmin": false
} {
"id": "7d0c…",
"email": "grace@example.com",
"status": "pending",
"…": "…",
"inviteToken": "8f14e45fceea167a…"
} Sign in first as shown on the identity page.
The worker logs a failure as
WARN [MailDeliveryWorker] A invite message to grace@example.com was rejected permanently: …
or … was given up on after 5 attempts: ….
What changes with a backend
| Route | No backend | With a backend |
|---|---|---|
POST /api/users/invites | returns inviteToken | sends invite; no token field |
POST /api/users/:id/invites/resend | returns inviteToken | sends invite_resent; no token field |
POST /api/users/:id/password-reset | returns resetToken | sends password_reset; no token field |
POST /api/auth/password-recovery (public, since 0.13.0) | 404 | 202 for every address; sends password_recovery to an active member |
POST /api/users/:id/reveal-link | 409 MAIL_NOT_CONFIGURED | link of the newest undelivered message |
The token field is absent, not empty, so a client reading it fails loudly.
reveal-link
Needs users:manage (administrators). Every use is audited as
user.invite_link_revealed (address and kind, never the link).
curl -s -X POST -b cookies.txt http://localhost:3000/api/users/7d0c…/reveal-link{
"kind": "invite",
"link": "https://cms.example.com/identity/accept-invite?token=8f14e45fceea167a",
"queuedAt": "2026-09-30T09:14:00.000Z",
"attempts": 3,
"lastError": "ECONNECTION: connect ETIMEDOUT"
}A delivered row is deleted, secret and all. After a successful send:
{
"statusCode": 409,
"error": "Conflict",
"code": "NO_REVEALABLE_LINK",
"message": "There is no undelivered message for this member; its link is not recoverable. Resend the invite or re-issue the reset instead: 7d0c…"
}Templates
A template is a function (context) => { subject, text, html? }. Override any
of invite, invite_resent, password_reset, password_recovery; the rest
keep the shipped copy. password_recovery has no actorName — nobody but the
recipient (or someone typing their address) asked — and its shipped copy ends
“If it was not you, ignore this message — your password stays as it is.” It
links to the same reset page as password_reset.
import { formatExpiry, type MailTemplate } from '@orthacms/mail-domain';
const invite: MailTemplate = (ctx) => ({
subject: `Join the ${ctx.productName} editorial team`,
text: [
`Hi ${ctx.recipientName ?? 'there'},`,
`${ctx.actorName ?? 'An editor'} added you to ${ctx.productName}.`,
`Accept before ${formatExpiry(ctx.expiresAt)} (UTC):`,
'',
ctx.link
].join('\n')
});
MailServerPlugin({ provider, config: { ...config.plugins.mail, templates: { invite } } });Context: { recipientEmail, recipientName?, actorName?, link, expiresAt, productName }.
The shipped copy is English and always includes a plain-text part.
Retries
| Attempt | Delay before it (±20% jitter) |
|---|---|
| 2 | 15 s |
| 3 | 1 min |
| 4 | 5 min |
| 5 | 15 min |
- A
MailPermanentErrorfrom the provider stops the row at once. - A retry re-sends the same rendered body, so any duplicate carries the same working link.
| Row state | Meaning |
|---|---|
| Pending | Queued, or waiting out a backoff |
Dead (dead_at set) | Attempts spent, or a permanent refusal |
| Swept | The token expired before hand-off |
| Gone | Delivered — the row is deleted |
How a message is queued
The plaintext token exists only inside the issuing transaction, so the message is rendered and queued in that transaction:
InviteMemberUseCase inside uow.run
├─ member saved
├─ InviteTokenService.rotate() plaintext exists only here
├─ mail_deliveries row rendered message, link included
└─ outbox: member.invited audit event, no secret
│ commit
MailDeliveryWorker claims with FOR UPDATE SKIP LOCKED, commits the claim
└─ MailProvider.send() no transaction openSee ADR-0018 in the decisions.
A custom backend
Implement MailProvider from @orthacms/mail-domain for a vendor whose wire is
not SMTP:
import {
MailPermanentError,
type MailMessage,
type MailProvider,
type MailReceipt
} from '@orthacms/mail-domain';
export function createAcmeMailProvider(options: { apiKey: string }): MailProvider {
return {
id: 'acme', // stamped on every delivery row
capabilities: { messageId: true, verifiable: true },
async send(message: MailMessage): Promise<MailReceipt> {
const response = await fetch('https://api.acme-mail.example/v1/send', {
method: 'POST',
headers: {
authorization: `Bearer ${options.apiKey}`,
'content-type': 'application/json'
},
body: JSON.stringify({
from: message.from,
to: message.to,
reply_to: message.replyTo,
subject: message.subject,
text: message.text,
html: message.html,
headers: message.headers // includes X-Orthacms-Mail-Kind
})
});
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
// Retrying will not help: stop the row now.
throw new MailPermanentError(`Acme refused the message: ${response.status}`);
}
if (!response.ok) {
throw new Error(`Acme is unavailable: ${response.status}`); // retried
}
const { id } = (await response.json()) as { id?: string };
return { providerMessageId: id };
},
// Required when capabilities.verifiable is true. A throw refuses the boot.
async verify(): Promise<void> {
const response = await fetch('https://api.acme-mail.example/v1/me', {
headers: { authorization: `Bearer ${options.apiKey}` }
});
if (!response.ok) throw new Error(`Acme rejected the API key: ${response.status}`);
}
};
}The plugin refuses to construct with an empty id, missing capabilities, or
verifiable: true without verify(). Test it with the
testkit patterns.
Permissions and limits
users:manage(Administrator) coversreveal-link.- No agent tool can send mail or reveal a link, on the copilot or MCP.
What this is not
- Transactional only: four messages. No lists or campaigns.
- Outbound only. Replies go to
replyTo. - No bounce handling or suppression list. “Delivered” means the relay accepted it.
- A dead message surfaces only as a
mail_deliveriesrow withdead_atset. No route lists them. - The worker is an in-process interval.
deliveryIntervalMs: 0makes a node enqueue-only.
Operations, DNS (SPF, DKIM, DMARC) and troubleshooting: configuring a mail backend.