The production backend. Resend, SES, Postmark, SendGrid, Mailgun, Google
Workspace, Microsoft 365 and any relay inside a perimeter all speak SMTP.
Built on nodemailer; nothing outside this package imports it.
It identifies itself as smtp on every delivery row.
Install
npm install @orthacms/mail-server @orthacms/mail-provider-smtpConfigure
# .env
MAIL_PROVIDER=smtp
APP_URL=https://cms.example.com
MAIL_FROM="Acme CMS <no-reply@example.com>"
# the relay — the only value with no default
SMTP_HOST=smtp.example.com
# 587 = STARTTLS (default), 465 = implicit TLS
SMTP_PORT=587
# leave EMPTY on 587; "true" only for 465. "false" is not the same as unset
SMTP_SECURE=
SMTP_USER=apikey
SMTP_PASSWORD=...
# refuse a relay that cannot upgrade to TLS — turn on for any internet relay
SMTP_REQUIRE_TLS=true
SMTP_CONNECTION_TIMEOUT_MS=10000| Variable | Default | Notes |
|---|---|---|
SMTP_HOST | required | Missing → boot error naming it |
SMTP_PORT | 587 | |
SMTP_SECURE | port === 465 | TLS from the first byte |
SMTP_USER | unset | No auth when unset |
SMTP_PASSWORD | unset | Password or API key |
SMTP_REQUIRE_TLS | false | |
SMTP_CONNECTION_TIMEOUT_MS | nodemailer’s default |
Read only when MAIL_PROVIDER=smtp. The sender is MAIL_FROM, a plugin
setting — see the mail plugin.
Typical relay values:
| Service | SMTP_HOST | SMTP_USER |
|---|---|---|
| Resend | smtp.resend.com | resend (password = API key) |
| Amazon SES | email-smtp.<region>.amazonaws.com | SMTP credentials from the SES console |
| Postmark | smtp.postmarkapp.com | server API token (password too) |
| SendGrid | smtp.sendgrid.net | apikey (password = API key) |
| Mailgun | smtp.mailgun.org | the domain’s SMTP login |
| Local Mailpit / MailHog | localhost, port 1025 | unset |
Check your provider’s documentation for current values.
The config is the same in both apps (reference apps/server/config/mail.ts,
generated config/mail.ts):
function smtpTransport(): SmtpMailProviderOptions {
return defined({
host: requireEnv('SMTP_HOST', 'MAIL_PROVIDER=smtp needs a relay to hand messages to — e.g. smtp.example.com.'),
port: readOptionalPositiveInt('SMTP_PORT'),
// unset → the adapter decides (465 = implicit TLS, otherwise STARTTLS)
secure: when(readEnv('SMTP_SECURE'), () => readFlag('SMTP_SECURE', false)),
user: readEnv('SMTP_USER'),
password: readEnv('SMTP_PASSWORD'),
requireTls: when(readEnv('SMTP_REQUIRE_TLS'), () => readFlag('SMTP_REQUIRE_TLS', false)),
connectionTimeoutMs: readOptionalPositiveInt('SMTP_CONNECTION_TIMEOUT_MS')
});
}All options
SmtpMailProviderOptions: host, port?, secure?, user?, password?,
requireTls?, allowInvalidCertificate?, connectionTimeoutMs?,
transport?.
allowInvalidCertificateaccepts a self-signed relay certificate. Off; leave it off.transportinjects anything withsendMail()(and optionallyverify()), for tests.
Register
// apps/server/src/plugins.ts
import { MailServerPlugin } from '@orthacms/mail-server';
import { createSmtpMailProvider } from '@orthacms/mail-provider-smtp';
MailServerPlugin({
provider: createSmtpMailProvider(config.plugins.mail.smtp),
config: config.plugins.mail
});Verify
Reachability from the server’s host first:
nc -vz smtp.example.com 587The boot check connects and authenticates. Success:
LOG [MailProviderCheck] Mail is enabled through the "smtp" provider, sending as Acme CMS <no-reply@example.com>.Failure refuses the boot:
Error: The mail provider "smtp" refused the configured sender "Acme CMS <no-reply@example.com>": SMTP relay smtp.example.com:587 refused the connection — EAUTH: 535 Authentication failedThen send a real invite and check the response has no inviteToken
(example).
For local development, run Mailpit and point at it:
docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit
# SMTP_HOST=localhost SMTP_PORT=1025 — open http://localhost:8025Ports
- 587 + STARTTLS: what hosted services expect. Leave
SMTP_SECUREempty. - 465: implicit TLS.
SMTP_SECUREdefaults totrueon this port. - 25: server-to-server, blocked outbound on most networks. A message that never leaves with nothing logged is often this.
Which failures are permanent
| SMTP response | Treated as |
|---|---|
5xx reply code | Permanent — row stops now |
EENVELOPE, EMESSAGE with no reply code | Permanent |
4xx (greylist, rate limit) | Retryable |
| Connection drop, timeout, DNS failure | Retryable |
A failed attempt keeps the relay’s reply text in last_error.
What “sent” means
send resolves when the relay accepts the message. A later bounce is
invisible to the CMS: the row is already deleted. To chase an accepted message,
search the relay’s log by recipient and time.
The boot check verifies the login, not deliverability
Without SPF, DKIM and DMARC on the sending domain, accepted mail still lands in spam. See configuring a mail backend.