--- title: Transactional email from a VPS that blocks SMTP summary: Send password codes and notifications through an HTTPS email API, from an authenticated domain, and prove delivery from the provider's events rather than your own logs. when_to_use: An app on a cheap VPS needs to send email (reset codes, alerts) and you have not confirmed the server can reach an SMTP port. prerequisites: A domain whose DNS you control (examples use Cloudflare), a Node app you can set env vars on, an account with an HTTP email provider (examples use Brevo; the shape is the same for Postmark, Resend, SES, Mailgun). related: [workflows/password-reset.md, workflows/browser-flow-testing.md] last_verified: 2026-09-26 --- # Transactional email from a VPS that blocks SMTP ## Goal Mail from `noreply@` that lands in the inbox (not spam), is DKIM-signed by ``, is sent over HTTPS so blocked SMTP ports do not matter, and whose delivery you can check with a command rather than a guess. ## Inputs (placeholders used) | Placeholder | Meaning | |---|---| | `` | the domain mail is sent from, e.g. `example.com` | | `` | SSH alias of the VPS | | `` | the app's directory / service name | | `` | the sender name people see | | `` | the email API (Brevo in the examples) | ## Steps ### 1. Find out whether the server can send mail at all Many VPS providers block outbound SMTP on new accounts. Test before you design anything around Postfix or nodemailer-over-SMTP: ```bash ssh 'for p in 25 465 587; do timeout 5 bash -c " '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. ### 2. Create the provider account and an API key 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. ### 3. Authenticate the sending domain Add `` 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.` | CNAME | provider value | DKIM key 1 — **DNS only, never proxied** | | `brevo2._domainkey.` | CNAME | provider value | DKIM key 2 — **DNS only** | | `` (apex) | TXT | `brevo-code:…` | ownership proof | | `_dmarc.` | TXT | `v=DMARC1; p=none; rua=mailto:@` | report, don't reject, while you learn | Rules: - **Never proxy a DKIM CNAME** (Cloudflare orange cloud). The receiver must see the provider's key, not Cloudflare's IPs. - **Never add a second SPF record.** A domain may have exactly one `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:`. - The ownership TXT sits **beside** any existing apex TXT records. That is fine; only SPF has the one-record rule. - Start DMARC at `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: ```bash curl -s -X PUT -H "api-key: $KEY" https://api.brevo.com/v3/senders/domains//authenticate curl -s -H "api-key: $KEY" https://api.brevo.com/v3/senders/domains/ | jq '.authenticated, .dns_records' ``` **Check:** `authenticated: true`, and `dig +short TXT | grep -c 'v=spf1'` prints `1` (or `0`), never `2`. ### 4. Create the sender ```bash curl -s -X POST -H "api-key: $KEY" -H 'content-type: application/json' \ https://api.brevo.com/v3/senders \ -d '{"name":"","email":"noreply@"}' ``` On an authenticated domain, any address on it may send. **Check:** `GET /v3/senders` lists it as active. ### 5. Add a mailer to the app — HTTPS, with a console fallback 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.) ```ts // email/mailer.ts export interface EmailMessage { to: string; subject: string; text: string; html?: string } export interface Emailer { send(msg: EmailMessage): Promise } export const consoleEmailer: Emailer = { async send(msg) { console.log(`[console emailer] to ${msg.to} — ${msg.subject}\n${msg.text}`); }, }; function parseFrom(v: string) { // "Name " 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@") : consoleEmailer; } ``` Send mail **after** replying to the user when the mail is not the answer itself: ```ts 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. ### 6. Configure production — and quote the sender ```bash # /.env on the server BREVO_API_KEY=… EMAIL_FROM=" >" ``` **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. ### 7. Send one real message and read the headers 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: > signed-by: ← 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. ### 8. Verify delivery from the provider, not from your app Your app's log line can only say the provider **accepted** the request. It cannot say the mail arrived. Ask the provider: ```bash curl -s -H "api-key: $KEY" \ "https://api.brevo.com/v3/smtp/statistics/events?limit=50&days=7&email=" \ | 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. ## Traps | 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: " or lands in spam | 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 ` 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)`) | ## Done when - [ ] Port test run; decision "HTTPS API" recorded in the project notes - [ ] Domain authenticated (DKIM ×2 DNS-only, ownership TXT, DMARC `p=none`), exactly one SPF record - [ ] Sender `noreply@` active - [ ] App uses the HTTPS mailer with a console fallback and a 10 s timeout - [ ] `.env` values with `<` are quoted; a deploy has run end to end - [ ] A real message reached a Gmail Inbox, `signed-by: ` - [ ] Provider event API shows `delivered` for it