- 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=smtpand 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_FROMorSMTP_HOSTrefuses 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.envkeys are there. Fill them in. - Scaffolded with Do not configure: install
@orthacms/mail-serverand@orthacms/mail-provider-smtpat your set’s version and register the plugin beforeUsersPlugin():
// 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
| Variable | Default | Sets |
|---|---|---|
MAIL_PROVIDER | unset — sends nothing | smtp. The monorepo’s own server also accepts console (logs messages); a generated app refuses anything but smtp |
APP_URL | required with a backend | The absolute URL links are built from. Scheme, no trailing slash |
MAIL_FROM | required with a backend | The sender, bare or Name <address> |
MAIL_REPLY_TO | unset | Where replies go |
MAIL_PRODUCT_NAME | Ortha CMS | What the copy calls the product |
MAIL_REVEAL_LINKS | false | Return the raw link as well as sending it. Debugging only |
MAIL_DELIVERY_INTERVAL_MS | 2000 | How often this process polls the queue |
MAIL_MAX_ATTEMPTS | 5 | Attempts 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:
| Record | Why |
|---|---|
| SPF | Authorises your relay to send as this domain |
| DKIM | Signs the message |
| DMARC | Tells 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):
| Route | Field that disappears |
|---|---|
POST /api/users/invites | inviteToken |
POST /api/users/invites/batch | inviteToken, on each item |
POST /api/users/:id/invites/resend | inviteToken |
POST /api/users/:id/password-reset | resetToken |
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_revealedto the activity log. 409withcode: "NO_REVEALABLE_LINK"— the message was delivered (its only readable copy is gone). Resend or re-issue instead.409withcode: "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
-
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.
-
Is anything sending? A process with
deliveryIntervalMs: 0queues without sending. If every process is set that way, nothing goes out. -
Is it dead?
dead_atis set;last_errorhas the relay’s words — usually a sender it refuses. -
Did the token expire first? Invitations last 7 days, resets 1 hour. An expired row is swept unsent.
-
Delivered to spam? Check the relay’s logs and DMARC reports.
-
Give the person their link with
reveal-linkwhile 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: 0inconfig/mail.ts(the environment reader refuses0):
// 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_URLand update token-reading scripts in the same deploy. - Off: remove
MAIL_PROVIDER. The routes return links again on the next boot.