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

Sending mail

Mail plugin @orthacms/mail-server

What to set so invitations and reset links are sent, and what to check when one does not arrive.

Documents 0.13.1 Updated Edit this page Report a problem

On this page
  • Default: nothing is sent. Invite and reset routes return the raw token; an admin relays the link by hand. The sign-in page’s Forgot it? tells people to ask an admin. That is a complete configuration.
  • Set MAIL_PROVIDER=smtp and the CMS sends invitations and reset links — and those routes stop returning the token. Since 0.13.0 it also turns on self-service recovery: Forgot it? becomes a form that mails a reset link to an active member.

The mechanism is on the mail plugin page.

The minimum

MAIL_PROVIDER=smtp
APP_URL=https://cms.example.com            # required: every link is built from it
MAIL_FROM="Ortha CMS <no-reply@cms.example.com>"   # required
SMTP_HOST=smtp.example.com                 # required
SMTP_PORT=587                              # STARTTLS; use 465 for implicit TLS
SMTP_USER=…
SMTP_PASSWORD=…
SMTP_REQUIRE_TLS=true                      # refuse a relay that cannot do TLS
  • A missing APP_URL, MAIL_FROM or SMTP_HOST refuses the boot, naming the variable.
  • Any SMTP relay works: Resend, SES, Postmark, SendGrid, Mailgun, Google Workspace, Microsoft 365, your own.

Is my app wired for mail?

  • Scaffolded with --mail mail-smtp: yes — config/mail.ts, the plugin and the .env keys are there. Fill them in.
  • Scaffolded with Do not configure: install @orthacms/mail-server and @orthacms/mail-provider-smtp at your set’s version and register the plugin before UsersPlugin():
// apps/server/src/plugins.ts
import { MailServerPlugin } from '@orthacms/mail-server';
import { createSmtpMailProvider } from '@orthacms/mail-provider-smtp';

...(config.plugins.mail
    ? [MailServerPlugin({ provider: createSmtpMailProvider(config.plugins.mail.smtp), config: config.plugins.mail })]
    : []),
UsersPlugin(),

Copy config/mail.ts from a freshly scaffolded app to feed it. Then npx orthacms migrate — the plugin owns mail_deliveries.

Every variable

VariableDefaultSets
MAIL_PROVIDERunset — sends nothingsmtp. The monorepo’s own server also accepts console (logs messages); a generated app refuses anything but smtp
APP_URLrequired with a backendThe absolute URL links are built from. Scheme, no trailing slash
MAIL_FROMrequired with a backendThe sender, bare or Name <address>
MAIL_REPLY_TOunsetWhere replies go
MAIL_PRODUCT_NAMEOrtha CMSWhat the copy calls the product
MAIL_REVEAL_LINKSfalseReturn the raw link as well as sending it. Debugging only
MAIL_DELIVERY_INTERVAL_MS2000How often this process polls the queue
MAIL_MAX_ATTEMPTS5Attempts before a message is a dead letter

SMTP_* settings are read only with MAIL_PROVIDER=smtp — see the SMTP provider.

APP_URL is never taken from the request

The Host header is caller-controlled. Building links from it would let an attacker turn your invitation into a phishing link.

Set up the sending domain first

Whether mail lands in Inbox or Spam is decided by DNS on the MAIL_FROM domain:

RecordWhy
SPFAuthorises your relay to send as this domain
DKIMSigns the message
DMARCTells receivers what to do when the others fail — and reports it
  • Take the exact values from your relay.
  • Use a subdomain you send nothing else from — no-reply@cms.example.com.
  • Send one real invitation to a different provider before inviting a team.

What changes in the API

With a backend configured, these routes stop returning the token (the field is absent, not empty):

RouteField that disappears
POST /api/users/invitesinviteToken
POST /api/users/invites/batchinviteToken, on each item
POST /api/users/:id/invites/resendinviteToken
POST /api/users/:id/password-resetresetToken

Update any script that reads those fields in the same deploy. The replacement is the audited reveal route (needs users:manage):

curl -X POST -b jar.txt https://cms.example.com/api/users/<user-id>/reveal-link
{
    "kind": "invite",
    "link": "https://cms.example.com/identity/accept-invite?token=8f14e45fceea167a",
    "queuedAt": "2026-09-30T10:02:11.000Z",
    "attempts": 3,
    "lastError": "ECONNECTION: connect ETIMEDOUT"
}
  • Each call writes user.invite_link_revealed to the activity log.
  • 409 with code: "NO_REVEALABLE_LINK" — the message was delivered (its only readable copy is gone). Resend or re-issue instead.
  • 409 with code: "MAIL_NOT_CONFIGURED" — this deployment sends no mail; the invite route already returned the link.

In the admin, the invite page ends on one line — “Emails sent to 2 people.” — instead of a link per person; resend and reset confirm with a toast.

Self-service recovery

POST /api/auth/password-recovery exists only with a backend — without one it is a 404. It answers 202 for every address before it looks anything up; behind it, an active member gets a password_recovery message (subject “Reset your … password”), at most three an hour per address and one a minute per member. A request over a limit, for an unknown address or for a suspended account is dropped without a word, so it never shows up in mail_deliveries — look for member.password_reset_issued in the activity log to see whether a link went out. See forgotten passwords.

When a message does not arrive

  1. Was it queued? Look for a row:

    SELECT kind, to_address, attempts, next_attempt_at, dead_at, last_error, expires_at
    FROM mail_deliveries
    WHERE user_id = '<user-id>';

    No row → the transaction rolled back, or no backend is configured.

  2. Is anything sending? A process with deliveryIntervalMs: 0 queues without sending. If every process is set that way, nothing goes out.

  3. Is it dead? dead_at is set; last_error has the relay’s words — usually a sender it refuses.

  4. Did the token expire first? Invitations last 7 days, resets 1 hour. An expired row is swept unsent.

  5. Delivered to spam? Check the relay’s logs and DMARC reports.

  6. Give the person their link with reveal-link while it is undelivered.

MAIL_REVEAL_LINKS is for diagnosis only

It puts a second copy of an account-takeover link into HTTP responses and any log they pass through. Turn it off when you are done.

Running the sender somewhere else

  • Several processes can send at once — claims are committed before any socket opens.
  • To send from one process only, give the others deliveryIntervalMs: 0 in config/mail.ts (the environment reader refuses 0):
// apps/server/config/mail.ts — inside defined({ … })
deliveryIntervalMs: readFlag('SENDER', false)
    ? readOptionalPositiveInt('MAIL_DELIVERY_INTERVAL_MS')
    : 0,

Queueing still happens everywhere, inside the transaction that issues the token.

Turning mail on or off

  • On: nothing migrates or backfills; links handed out earlier keep working until they expire. Set APP_URL and update token-reading scripts in the same deploy.
  • Off: remove MAIL_PROVIDER. The routes return links again on the next boot.