oyware [email protected]

Email from a server that can't send it, and a password reset that lands in the right app

Blocked SMTP ports, a sending domain in four DNS records, and why the reset is a six-digit code instead of a link

2026-09-20Brevo HTTP API, Cloudflare DNS, Fastify, Prisma, argon2 / scrypt

Until 20 September none of my apps had a way back in. Forget your password on the gym tracker and the answer was "ask Omar". That is fine for three friends and wrong for anything I would sell.

The reset itself turned out to be the easy half. The hard half was the two things around it: getting mail out of a server that is not allowed to send any, and making sure the reset finishes inside the app on the phone, not in some other browser. Both were decided by facts I only found by testing. Almost nothing here is what I would have guessed on the first day.

What this covers


The shape of it

  phone (installed app)                          droplet
  ─────────────────────                          ───────
  Forgot password ── POST /auth/forgot ──▶  API ── hash code, store row
        │                                    │
        │                                    └── HTTPS ──▶ Brevo ──▶ inbox
        │                                                            │
        │   ◀────────────── person reads "418 392" ──────────────────┘
        │
  types code + new password ── POST /auth/reset ──▶ API
        │                                    ├── delete every session
        ◀──────── new session cookie ────────┘── start one for this device

Two things to notice. Mail leaves over HTTPS, not SMTP. And the loop closes in the same app that opened it — the email carries something a person reads, not something they tap.


The droplet cannot send mail

The obvious plan was the one every tutorial gives: install Postfix, or point nodemailer at an SMTP server, done. EpisodeTracker already sent its daily show emails through Gmail's SMTP on my Mac, so I assumed the same code would work on the server.

It did not, and before building anything I checked why:

Port What it is for From the droplet
25 server-to-server mail times out
465 SMTP over TLS times out
587 SMTP submission times out
443 HTTPS fine
for p in 25 465 587; do
  nc -vz -w 5 smtp.gmail.com $p 2>&1 | tail -1
done
# three timeouts — not "refused", which would mean something answered

A timeout, not a refusal. The packets never leave the machine: the provider blocks outbound SMTP on new accounts, which is common on cheap VPS plans because it is how spam gets sent. Installing a mail server changes nothing — the mail server would hit the same wall.

Even with the ports open, self-hosted mail would not arrive. The droplet's reverse DNS (what its IP says its own name is) is a .local placeholder, and Gmail rejects mail from an address whose reverse DNS does not match the sender. Fixing that means asking the provider, then spending months building a sending reputation from zero.

Don't read "own server, no hosted backend" as "so host your own mail". Everything else on this box is mine. Mail is the settled exception: outgoing mail needs a sender with a reputation, and that is the one thing a $6 droplet cannot have.


Brevo, over HTTPS

HTTPS is open, so the answer is a mail provider with an HTTP API. I chose Brevo: EpisodeTracker already used it for its show alerts, the free tier is about 300 emails a day, and these apps send a handful.

The whole integration is one fetch:

// api/src/email/mailer.ts (gym tracker)
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: { name: from.name, email: from.email },
    to: [{ email: msg.to }],
    subject: msg.subject,
    textContent: msg.text,
    ...(msg.html ? { htmlContent: msg.html } : {}),
  }),
  // Brevo is a third party on the request path of a user waiting for a
  // code; never let it hang the worker.
  signal: AbortSignal.timeout(10_000),
});

No SDK, no nodemailer. And a fallback that matters more than it looks:

export function getEmailer(): Emailer {
  const key = brevoApiKey();
  return key ? brevoEmailer(key) : consoleEmailer;
}

With no BREVO_API_KEY, the message is printed to the log instead of sent. Local development and the test suite need no account, send nothing, and cannot accidentally email a real address.

A new app on this server needs exactly two environment variables: the API key, and who the mail is from.


Making the mail yours: authenticating the domain

Out of the box, Brevo sends "from" an address you have verified — at first a Gmail address. Mail from a Gmail address that did not come through Gmail's own servers is exactly what spam filters look for. It also says nothing about oyware.

So oyware.com became an authenticated sending domain. Then any address on it may send — [email protected] for all the apps — and every message is signed by the domain itself.

The records, in Cloudflare:

Record Type What it does
brevo1._domainkey CNAME DKIM key 1 — lets receivers check the signature
brevo2._domainkey CNAME DKIM key 2 — so Brevo can rotate keys without you
@ (apex) TXT brevo-code:… proves to Brevo that you control the domain
_dmarc TXT v=DMARC1; p=none; … tells receivers what to do when checks fail

The DKIM records must be DNS only

Cloudflare's default for a new CNAME is proxied — the orange cloud. For a website, that is the point. For a DKIM record it breaks everything: a proxied CNAME answers with Cloudflare's own addresses instead of pointing at Brevo's key, so receivers look up the signature key and get nothing usable.

Set both _domainkey CNAMEs to DNS only (grey cloud). Anything that is not a web server your visitors connect to — mail records, verification records — should never be proxied.

Do not add a second SPF record

Brevo's setup screens talk about SPF, and the reflex is to add v=spf1 include:… ~all for Brevo. Don't.

oyware.com already has one SPF record, for Cloudflare's email routing (which forwards [email protected] to my inbox):

v=spf1 include:_spf.mx.cloudflare.net ~all

The SPF rule is that a domain has exactly one v=spf1 record. Two is not "both apply" — two is a permanent error, and receivers treat SPF as failed for every sender on the domain, including the forwarding that was working a minute ago.

Brevo does not need an SPF include here at all. SPF checks the envelope sender, which is Brevo's own bounce domain, not yours. Alignment for DMARC comes from the DKIM signature. So the brevo-code TXT goes at the apex beside the SPF record, and the SPF record is left exactly as it was.

DMARC at p=none

p=none means "report, don't reject". It is the right start: DMARC is present (Gmail and Yahoo now expect it from bulk senders), and nothing legitimate can be bounced by a mistake while you are still learning what sends mail as your domain. Tighten it later.

How to know it worked

Send a real message to a Gmail inbox, open it, and look at the details under the sender:

from:       Gym Tracker <[email protected]>
signed-by:  oyware.com

signed-by: oyware.com is the line that matters. It landed in the Inbox, not Spam, on the first try.

Brevo's setup can all be done over its API instead of its dashboard — /v3/senders/domains to add the domain and read back the records it wants, PUT /v3/senders/domains/<domain>/authenticate to have it check them, /v3/senders for the sender. I ran those from the droplet, so the API key never left the server.

Cloudflare's dashboard reads fine and refuses writes. I tried to add the records through the dashboard's internal API from a signed-in tab: GETs work, POSTs get a WAF 403. Read with the API, write through the UI. And when pasting a long value into a field — a DKIM target, a TXT string — read the field back before saving: on the first try the value was cut short.


Every reset I had used as a customer sends a link. So the first design I wrote down was a link: click it, land on a "choose a new password" page, done. I recommended it.

Then the question was asked back: why a link? And following the answer through showed it would fail for most of the people using these apps.

The apps are installed. The gym tracker is a PWA that lives on the home screen; so is EpisodeTracker. On iOS, a home-screen web app runs in its own storage container, separate from Safari's — its own cookies, its own logins — even though it is the same domain.

Now follow the link:

installed app: "Forgot password"  →  email arrives  →  tap the link
                                                          │
                                         iOS opens it in SAFARI, not the app
                                                          │
                            reset succeeds, login cookie is set… in Safari
                                                          │
             back to the app icon: still logged out, holding a password
             they chose thirty seconds ago

Gmail's in-app browser splits the same way. The reset works and the person is still locked out of the thing they were trying to open.

A code does not have this problem. The email says 418 392; the person switches back to the app and types it; the reset finishes in the app that asked for it, and that app gets the new session.

The argument for a link Why it loses here
One tap is better UX One tap into the wrong browser is a dead end
Codes get mistyped A typo costs a retype; the split costs being locked out
It's the same origin, so the app is fine The split is not about origin — iOS gives the installed app its own cookie jar for the same origin
The link will open the app On Android, sometimes. On iOS it opens Safari

Code-only is also less code than a link: no token route, no reset page that can 404, no "this link has expired" screen, no "already used" state. If desktop users ever matter enough, a link can go beside the code. Never instead of it.

The email puts the code in the subject too — 418392 is your Gym Tracker reset code — so it can be read off the notification without opening anything. In the body the digits are grouped in threes, because that is what a person can hold in their head between two apps. The input strips everything that is not a digit, so a code pasted as 418 392 still works.


The rules that make a reset safe

A password reset is a login that bypasses the password, so it gets the same care. Most of these are standard. Two of them are not obvious, and both are about not telling a stranger who has an account.

The data

One row per user:

model PasswordReset {
  userId          String   @id
  // argon2 of the six digits. Six digits is little entropy, so it gets the slow
  // hash and `attempts` below, not a plain digest.
  codeHash        String
  expiresAt       DateTime
  attempts        Int      @default(0)
  // Codes sent in the window that began at windowStartedAt, so a forgotten
  // password cannot be turned into a flood of mail to someone else's inbox.
  sentCount       Int      @default(1)
  windowStartedAt DateTime @default(now())
  createdAt       DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

Keyed on userId, so asking again replaces the code. There is never a second one still working.

Rule Value Why
Code 6 digits, crypto.randomInt Math.random() * n and randomBytes % n both skew the distribution
Stored as the app's password hash (argon2 / scrypt) six digits is a million guesses; a fast hash would fall in seconds offline
Lives 30 minutes long enough to find the mail, short enough that a leaked inbox goes stale
Wrong tries 5, then the code is deleted 5 in a million is the whole budget an attacker gets
Codes per address 3 per hour so nobody can use your form to flood someone else's inbox
Endpoints rate-limited per IP, like login the same limiter the rest of /auth/ uses

Always answer "ok"

POST /auth/forgot answers { ok: true } whether the address has an account or not, and whether a mail was sent or the hourly allowance was already used up. Anything else — "no account with that email" — turns the form into a lookup service for who uses the app.

Hash before you look

This is the part that is easy to get wrong while believing you got it right. The natural code is:

const user = await findUser(email);
if (!user) return { ok: true };        // fast
const codeHash = await argon2(code);   // slow — ~tens of ms

The answer is identical, but the time is not. An unknown address returns immediately; a real one spends a slow hash first. Measure the response time and you have the same lookup service back.

So the slow work happens first, for everybody:

app.post("/auth/forgot", limited, async (request) => {
  const body = parse(forgotBody, request.body);
  const code = newCode();
  const codeHash = await hashPassword(code);   // before the lookup, always
  const user = await prisma.user.findUnique({ where: { email: body.email } });
  const ok: { ok: true; devCode?: string } = { ok: true };
  if (!user) return ok;
  // …
});

The reset side does the same: an unknown address still runs a real hash check against nothing before failing.

One message for every failure

Unknown address, no code requested, expired, wrong digits, out of tries — all five get the same answer:

That code is wrong or has expired. Ask for a new one.

They mean the same thing to the person (ask again), and distinguishing them would tell a stranger which of those states an address is in.

Send after answering

void getEmailer()
  .send({ to: body.email, ...message })
  .catch((err) => request.log.error({ err }, "reset email was not sent"));

Not awaited. The person is waiting on this request; Brevo is not on their critical path, and the code is already valid either way. A send failure is mine to read in the log, not theirs to decode.

On success, end everything — then log them in

const [updated] = await prisma.$transaction([
  prisma.user.update({ where: { id: user.id }, data: { passwordHash } }),
  prisma.authSession.deleteMany({ where: { userId: user.id } }),
]);
await startSession(reply, user.id);

Every session that account had is deleted. Resetting a password is how you throw out someone who got in — a password change that leaves their session alive changes nothing.

And then this device gets a fresh session. The person is standing in the app, having just typed a new password. Sending them to the login screen to type it again is a small insult, and on a phone a real chance to typo it and start over.


The race I shipped, and found five days later

The first version checked attempts like this: read the row, run the slow hash, write attempts + 1. It passed every test.

On 25 September a code review of the whole app asked what happens if the guesses arrive at the same time. Ten requests fired together all read attempts = 0, all spend their argon2 time, all write back 1. Ten guesses, one try. The limit of five was really "five rounds of as many parallel guesses as you like".

The fix is to spend the try before checking the code, in one conditional write:

// Spend a try before checking the code, in one conditional write. Reading
// the count and writing it back after the slow hash let parallel guesses all
// read 0, so ten guesses cost one try.
const taken = await prisma.passwordReset.updateMany({
  where: { userId: user.id, attempts: { lt: MAX_ATTEMPTS }, expiresAt: { gt: now } },
  data: { attempts: { increment: 1 } },
});

The database does the check and the increment as one step, so the sixth parallel request finds nothing to update. And the same idea claims the reset itself: the request that deletes the row owns it, so the right code sent twice at once sets one password, not two.

const used = await prisma.passwordReset.deleteMany({
  where: { userId: user.id, codeHash: reset.codeHash },
});
if (used.count === 0) throw wrong;

Anything shaped read → slow work → write back is a race. If the check and the update can be one conditional statement, make them one.


Testing without an inbox

Tests and local development have no mailbox to read the code from. So outside production, /auth/forgot returns the code in its answer:

if (!isProduction()) ok.devCode = code;
return ok;

The web page pre-fills the code field from it, and the browser flow reads it from there. That is safe only if production can never do it — so there is a test that asserts the production response carries no devCode. After the deploy I checked the live response by hand as well.

The browser flow (headless Chrome at phone width) walks the whole path: the address typed on the login screen carries over to Forgot password, a wrong code is refused without losing the screen, the right one logs in, the old password is dead, the new one works, and the same code is refused a second time.

It refuses to run against the live host — it changes a password:

if (/tracker\.oyware\.com/.test(ORIGIN)) {
  console.error("Refusing to run against production: this flow changes a password.");
  process.exit(1);
}

Production got one manual run instead, with a throwaway account on a plus-address of my own inbox: the code arrived in the Inbox, from [email protected], signed by oyware.com. Then the account was deleted.


Two deploy traps

< in an env file is a redirect

The sender goes in .env as a name and an address:

EMAIL_FROM=Gym Tracker <[email protected]>

The deploy script loads that file with bash — set -a && . ./.env && set +a — and to bash, that line is a command: run Tracker with EMAIL_FROM=Gym set, reading input from a file called [email protected]. The deploy died partway through, after the build and before the database migration, with an error about a missing file that looks nothing like an email problem.

EMAIL_FROM="Gym Tracker <[email protected]>"

Quote any value in a sourced .env that contains <, >, spaces or quotes. The example file now says so on the line above.

A new screen needs a server route too

EpisodeTracker's server only hands the React app to paths it knows about. /forgot worked when you reached it by tapping "Forgot password?" — in-app navigation never asks the server — and would have 404'd on a refresh, a bookmark, or a shared link. A test that compares the React routes with the server's list caught it before deploy.

curl on an SPA route returns 404 without -H 'Accept: text/html'. That looks exactly like the bug above and is not it. Check /login the same way: if it 404s too, it is the header.


Building it twice

The gym tracker got the reset first, at 23:03 on the 20th. EpisodeTracker got the same flow forty minutes later, and moved its daily show emails onto [email protected] at the same time — so they are now signed by the domain instead of coming from a Gmail sender.

Almost none of the code carried over:

Gym tracker EpisodeTracker
Password hash argon2 scrypt (Node built-in)
Validation zod Fastify JSON schema
Tests API tests against a real test database plain unit tests, rules in a pure module
Styling Tailwind + shadcn hand-written CSS (at the time)
Where routes are known Nginx try_files a server-side route list

All of the decisions did: code not link, the limits, hash-before-lookup, one error message, end every session and log in here, devCode with a test against it, Brevo as the only mail path, the quoting trap.

So the decisions became a reusable skill — a checklist an agent loads when a new site on this server needs a way back in — rather than a shared library. A library would have fought each app's hash, CSS and test harness. The checklist fits all of them, and the third site that needs a reset starts from every answer in this guide instead of rediscovering it.


What I would tell myself on the 19th