kitdocs · github

Transactional email from a VPS that blocks SMTP

raw markdown workflows/email-sending.md verified 2026-09-26

Goal

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.

Inputs (placeholders used)

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)

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:

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.

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

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.

4. Create the sender

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.

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

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

6. Configure production — and quote the sender

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

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

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:

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.

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

Done when