Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Mail plugin

Mail plugin @orthacms/mail-server

Sending the invitation and the password-reset link, instead of handing them to an administrator to relay.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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-smtp

create-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.

BackendPackageFor a deployment?
SMTP@orthacms/mail-provider-smtpyes — Resend, SES, Postmark, SendGrid, Mailgun, Google Workspace, Microsoft 365…
Console@orthacms/mail-provider-consoleno — development only
Testkit@orthacms/mail-provider-testkitno — tests only

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=true
  • APP_URL and MAIL_FROM are checked with requireEnv: missing, the boot fails with a message naming the variable.
  • Any other MAIL_PROVIDER value fails the boot. A generated app accepts smtp only.

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.

OptionDefaultNotes
appUrlrequiredAbsolute URL links are built from
fromrequiredBare address or Name <address>
replyTounset
productNameOrtha CMS
revealLinksfalseReturns the raw token as well as sending
deliveryIntervalMs20000 = never send from this process
batchSize20Messages claimed per tick
maxAttempts5
claimLeaseMs60000Must exceed one hand-off
sweepIntervalMs300000Expired rows are swept this often
templatesshipped copyPartial 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:

Example language
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

RouteNo backendWith a backend
POST /api/users/invitesreturns inviteTokensends invite; no token field
POST /api/users/:id/invites/resendreturns inviteTokensends invite_resent; no token field
POST /api/users/:id/password-resetreturns resetTokensends password_reset; no token field
POST /api/auth/password-recovery (public, since 0.13.0)404202 for every address; sends password_recovery to an active member
POST /api/users/:id/reveal-link409 MAIL_NOT_CONFIGUREDlink of the newest undelivered message

The token field is absent, not empty, so a client reading it fails loudly.

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

AttemptDelay before it (±20% jitter)
215 s
31 min
45 min
515 min
  • A MailPermanentError from the provider stops the row at once.
  • A retry re-sends the same rendered body, so any duplicate carries the same working link.
Row stateMeaning
PendingQueued, or waiting out a backoff
Dead (dead_at set)Attempts spent, or a permanent refusal
SweptThe token expired before hand-off
GoneDelivered — 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 open

See 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) covers reveal-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_deliveries row with dead_at set. No route lists them.
  • The worker is an in-process interval. deliveryIntervalMs: 0 makes a node enqueue-only.

Operations, DNS (SPF, DKIM, DMARC) and troubleshooting: configuring a mail backend.