Mail from noreply@<domain> that lands in the inbox (not spam), is DKIM-signed by
<domain>, is sent over HTTPS so blocked SMTP ports do not matter, and whose
delivery you can check with a command rather than a guess.
| Placeholder | Meaning |
|---|---|
<domain> |
the domain mail is sent from, e.g. example.com |
<server> |
SSH alias of the VPS |
<app> |
the app's directory / service name |
<App Name> |
the sender name people see |
<provider> |
the email API (Brevo in the examples) |
Many VPS providers block outbound SMTP on new accounts. Test before you design anything around Postfix or nodemailer-over-SMTP:
ssh <server> 'for p in 25 465 587; do
timeout 5 bash -c "</dev/tcp/smtp.gmail.com/$p" && echo "$p open" || echo "$p blocked"
done'
ssh <server> 'dig +short -x $(curl -s https://api.ipify.org)' # reverse DNS of the server
Check: if any port says blocked, or the reverse DNS is empty or a
provider-internal name (e.g. something.local), do not run your own mail
server. The packets do not leave the machine, and a missing/odd rDNS is rejected
by Gmail anyway. Use an HTTPS API (next steps). In one real setup all three ports
timed out; installing Postfix would have changed nothing.
Create the account, generate an API key, and keep it only in the server's .env
(never in the repo, never pasted into chat). Many providers let you do every
remaining step through their API, so the key never has to leave the server.
Check: curl -s -H "api-key: $KEY" https://api.brevo.com/v3/account returns
your plan, not a 401.
Add <domain> as a sending domain in the provider. It gives you a set of DNS
records. With Brevo they are:
| Record | Type | Value | Notes |
|---|---|---|---|
brevo1._domainkey.<domain> |
CNAME | provider value | DKIM key 1 — DNS only, never proxied |
brevo2._domainkey.<domain> |
CNAME | provider value | DKIM key 2 — DNS only |
<domain> (apex) |
TXT | brevo-code:… |
ownership proof |
_dmarc.<domain> |
TXT | v=DMARC1; p=none; rua=mailto:<you>@<domain> |
report, don't reject, while you learn |
Rules:
v=spf1 TXT;
two makes SPF fail for all mail on the domain. If an SPF record already exists
(e.g. for inbound forwarding), leave it. Brevo authenticates with DKIM and does
not need an SPF include. If your provider does need one, edit the existing
record to add its include:.p=none. Gmail and Yahoo expect a DMARC record from senders;
p=none satisfies that without bouncing anything while you find out what else
sends as your domain. Tighten later.Then ask the provider to verify:
curl -s -X PUT -H "api-key: $KEY" https://api.brevo.com/v3/senders/domains/<domain>/authenticate
curl -s -H "api-key: $KEY" https://api.brevo.com/v3/senders/domains/<domain> | jq '.authenticated, .dns_records'
Check: authenticated: true, and
dig +short TXT <domain> | grep -c 'v=spf1' prints 1 (or 0), never 2.
curl -s -X POST -H "api-key: $KEY" -H 'content-type: application/json' \
https://api.brevo.com/v3/senders \
-d '{"name":"<App Name>","email":"noreply@<domain>"}'
On an authenticated domain, any address on it may send.
Check: GET /v3/senders lists it as active.
One provider plus a fallback, not a framework. With no key the message is logged, so local dev and tests need no account and send nothing. (TypeScript, any Node server.)
// email/mailer.ts
export interface EmailMessage { to: string; subject: string; text: string; html?: string }
export interface Emailer { send(msg: EmailMessage): Promise<void> }
export const consoleEmailer: Emailer = {
async send(msg) { console.log(`[console emailer] to ${msg.to} — ${msg.subject}\n${msg.text}`); },
};
function parseFrom(v: string) { // "Name <addr@domain>"
const m = v.match(/^\s*(.*?)\s*<([^>]+)>\s*$/);
return m ? { name: m[1], email: m[2] } : { name: "", email: v.trim() };
}
function brevoEmailer(apiKey: string, from: string): Emailer {
const sender = parseFrom(from);
return {
async send(msg) {
const res = await fetch("https://api.brevo.com/v3/smtp/email", {
method: "POST",
headers: { "api-key": apiKey, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
sender, to: [{ email: msg.to }], subject: msg.subject,
textContent: msg.text, ...(msg.html ? { htmlContent: msg.html } : {}),
}),
// A third party on the request path: never let it hang the worker.
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`provider refused: ${res.status} ${await res.text().catch(() => "")}`);
},
};
}
/** Read env lazily so a test can set the key before a case. */
export function getEmailer(): Emailer {
const key = process.env.BREVO_API_KEY;
return key ? brevoEmailer(key, process.env.EMAIL_FROM ?? "noreply@<domain>") : consoleEmailer;
}
Send mail after replying to the user when the mail is not the answer itself:
void getEmailer().send(message).catch((err) => request.log.error({ err }, "email not sent"));
Check: locally with no key, the message appears in the server log.
# <app>/.env on the server
BREVO_API_KEY=…
EMAIL_FROM="<App Name> <noreply@<domain>>"
The quotes are required. Deploy scripts often source .env (or set -a; . .env)
with bash, and an unquoted < is a shell input redirect: the deploy dies with
"No such file or directory", usually halfway (after the build, before the
migration). Quote every value containing <, >, spaces, &, $ or quotes.
Check: bash -n <(cat .env) shows no syntax error, and
set -a; . ./.env; echo "$EMAIL_FROM" prints the full value.
Send to a real Gmail inbox (a +tag address on your own account works). In Gmail,
open the message → the "details" arrow under the sender:
from: <App Name> <noreply@<domain>>
signed-by: <domain> ← the line that matters
Check: it is in Inbox, not Spam, and signed-by is your domain (not the
provider's). If signed-by shows the provider's domain, DKIM is not authenticated
yet: go back to step 3.
Your app's log line can only say the provider accepted the request. It cannot say the mail arrived. Ask the provider:
curl -s -H "api-key: $KEY" \
"https://api.brevo.com/v3/smtp/statistics/events?limit=50&days=7&email=<recipient>" \
| jq -r '.events[] | [.date, .event, .subject] | @tsv'
Look for delivered (and opened). requests only means submitted; hardBounces,
blocked, spam are the failures. Events lag 1–2 minutes: poll, don't conclude
from one empty read.
Check: a delivered event for the test message.
| Symptom | Cause | Fix |
|---|---|---|
| nodemailer works on the laptop, times out in production | the VPS blocks outbound SMTP | use the provider's HTTPS API (step 5) |
| Gmail says "signed-by: |
domain not authenticated, or DKIM CNAME proxied | add the DKIM records as DNS-only; re-run authenticate |
| All mail on the domain starts failing SPF, including inbound forwarding replies | a second v=spf1 record was added |
merge into one record |
Deploy dies at "migrate" with No such file or directory |
unquoted EMAIL_FROM=Name <addr> in a sourced .env |
quote the value |
| App log says "sent", user says nothing arrived | logs count attempts, not deliveries | read the provider's event API (step 8); then check the user's own notification settings in the DB — often they are simply off |
| Can't log in to the provider dashboard with your usual SSO | the account was registered under another address | don't fight the login; everything above works through the API with the key |
| Reset request is slow for real accounts, fast for unknown ones | the mail send was awaited on the request path | send after replying (void send().catch(log)) |
p=none), exactly one SPF recordnoreply@<domain> active.env values with < are quoted; a deploy has run end to endsigned-by: <domain>delivered for it