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

SMTP relay

Mail plugin @orthacms/mail-server

The one backend meant for production — and every hosted mail service speaks it.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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

Configure

# .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
VariableDefaultNotes
SMTP_HOSTrequiredMissing → boot error naming it
SMTP_PORT587
SMTP_SECUREport === 465TLS from the first byte
SMTP_USERunsetNo auth when unset
SMTP_PASSWORDunsetPassword or API key
SMTP_REQUIRE_TLSfalse
SMTP_CONNECTION_TIMEOUT_MSnodemailer’s default

Read only when MAIL_PROVIDER=smtp. The sender is MAIL_FROM, a plugin setting — see the mail plugin.

Typical relay values:

ServiceSMTP_HOSTSMTP_USER
Resendsmtp.resend.comresend (password = API key)
Amazon SESemail-smtp.<region>.amazonaws.comSMTP credentials from the SES console
Postmarksmtp.postmarkapp.comserver API token (password too)
SendGridsmtp.sendgrid.netapikey (password = API key)
Mailgunsmtp.mailgun.orgthe domain’s SMTP login
Local Mailpit / MailHoglocalhost, port 1025unset

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

  • allowInvalidCertificate accepts a self-signed relay certificate. Off; leave it off.
  • transport injects anything with sendMail() (and optionally verify()), 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 587

The 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 failed

Then 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:8025

Ports

  • 587 + STARTTLS: what hosted services expect. Leave SMTP_SECURE empty.
  • 465: implicit TLS. SMTP_SECURE defaults to true on 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 responseTreated as
5xx reply codePermanent — row stops now
EENVELOPE, EMESSAGE with no reply codePermanent
4xx (greylist, rate limit)Retryable
Connection drop, timeout, DNS failureRetryable

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.