Email infra (Brevo SMTP via nodemailer) + F-2 partial mitigation for issue #27#33
Merged
Conversation
ADR-0005 picks nodemailer v8 over emailjs / provider-specific SDKs
because it's the de-facto Node SMTP library (10M+ weekly DLs, MIT-0,
no critical CVEs in v8.x line) AND it keeps the provider choice in
config so Brevo → SES / Postmark / Resend is a swap, not a refactor.
What ships:
- adr/0005-adopt-nodemailer-for-smtp.md — full ADR per dep-add (DP6),
with the four alternatives surveyed, operational pre-reqs spelled
out (Brevo authorized IPs, SPF/DKIM/DMARC records), and threat
model delta callout.
- src/services/email.ts — sendMail({to, subject, html, text}) wrapper,
fire-and-forget contract (never throws), { ok:true, messageId } on
success, { ok:false, error } on failure, { ok:false, skipped:true }
when SMTP_HOST is empty (dev env). Recipient address hashed in log
lines per DPDP §8 (we don't ship end-user emails to log aggregators).
- src/services/email-templates.ts — welcomeEmail + signupAttemptedNoticeEmail.
Both return { subject, html, text }; html and text carry the same
information; both have Pramaan + IN202311041001 footer; user-supplied
fields HTML-escaped. Forbidden content (API keys, password values,
biometric-derived data) absent and asserted in tests.
- src/config/index.ts — adds config.email with SMTP_HOST/SMTP_PORT/
SMTP_USER/SMTP_PASSWORD/EMAIL_FROM/EMAIL_FROM_NAME env vars.
- .env.example — adds documented placeholders for all six. Real
credentials live in /opt/zeroauth/.env on the VPS only.
F-2 partial mitigation (issue #27):
- /api/console/signup duplicate-email path now (a) runs scrypt to
equalize timing with the fresh-signup path (closes the timing-oracle
half of the leak), (b) sends a "someone tried to sign up with your
email" notice to the legitimate account holder out-of-band.
- /api/console/signup fresh path now sends a welcome email after the
201 response (out-of-band, fire-and-forget — never blocks the
request). Email NEVER contains the API key (the existing dashboard
one-time-reveal stays the only key-exposure surface, per
security-policy.md §10).
- The 201/409 status-code split STAYS for now because the byte-
identical fix (return 202 always + email-verification flow) is a
breaking change to the dashboard signup-then-reveal-key flow + the
Playwright happy path; that's the v2 mitigation, deferred to a
follow-up PR.
Tests: 194 → 228 (+34 new):
- tests/email.test.ts (24) — sendMail unconfigured no-op,
configured envelope shape, replyTo handling, error returns,
verifySmtp success/failure, welcomeEmail + signupAttemptedNoticeEmail
shape + HTML-escape + Pramaan footer + no credentials-leak assertions
- tests/console-signup.test.ts (10) — fresh signup 201 + welcome
email queued + no API key in body, duplicate signup 409 + notice
email to legitimate holder + no tenant created + no tenant_id leak
in 409 body + scrypt-bound minimum response time (>= 10ms),
invalid-input 400s that skip both DB lookup and email send
Lint: 0 errors. Typecheck clean.
Production deploy pre-requisites:
1. Add 104.207.143.14 to Brevo Settings → SMTP & API → Authorized IPs
2. Add SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / EMAIL_FROM /
EMAIL_FROM_NAME to /opt/zeroauth/.env on the VPS
3. (Optional but recommended) Add SPF + DKIM + DMARC records on
zeroauth.dev via Hostinger:
- SPF: TXT @ "v=spf1 include:spf.brevo.com ~all"
- DKIM: TXT mail._domainkey <value from Brevo dashboard>
- DMARC: TXT _dmarc @ "v=DMARC1; p=quarantine; rua=mailto:dmarc@zeroauth.dev"
Without (1), every SMTP login returns 5.7.1 Unauthorized IP and
sendMail() returns { ok:false, error: 'Unauthorized IP' } — the
service still runs, just doesn't deliver. Without (3), Brevo-sent
mail may land in spam.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pulkitpareek18
added a commit
that referenced
this pull request
May 15, 2026
ADR-0005 picks nodemailer v8 over emailjs / provider-specific SDKs
because it's the de-facto Node SMTP library (10M+ weekly DLs, MIT-0,
no critical CVEs in v8.x line) AND it keeps the provider choice in
config so Brevo → SES / Postmark / Resend is a swap, not a refactor.
What ships:
- adr/0005-adopt-nodemailer-for-smtp.md — full ADR per dep-add (DP6),
with the four alternatives surveyed, operational pre-reqs spelled
out (Brevo authorized IPs, SPF/DKIM/DMARC records), and threat
model delta callout.
- src/services/email.ts — sendMail({to, subject, html, text}) wrapper,
fire-and-forget contract (never throws), { ok:true, messageId } on
success, { ok:false, error } on failure, { ok:false, skipped:true }
when SMTP_HOST is empty (dev env). Recipient address hashed in log
lines per DPDP §8 (we don't ship end-user emails to log aggregators).
- src/services/email-templates.ts — welcomeEmail + signupAttemptedNoticeEmail.
Both return { subject, html, text }; html and text carry the same
information; both have Pramaan + IN202311041001 footer; user-supplied
fields HTML-escaped. Forbidden content (API keys, password values,
biometric-derived data) absent and asserted in tests.
- src/config/index.ts — adds config.email with SMTP_HOST/SMTP_PORT/
SMTP_USER/SMTP_PASSWORD/EMAIL_FROM/EMAIL_FROM_NAME env vars.
- .env.example — adds documented placeholders for all six. Real
credentials live in /opt/zeroauth/.env on the VPS only.
F-2 partial mitigation (issue #27):
- /api/console/signup duplicate-email path now (a) runs scrypt to
equalize timing with the fresh-signup path (closes the timing-oracle
half of the leak), (b) sends a "someone tried to sign up with your
email" notice to the legitimate account holder out-of-band.
- /api/console/signup fresh path now sends a welcome email after the
201 response (out-of-band, fire-and-forget — never blocks the
request). Email NEVER contains the API key (the existing dashboard
one-time-reveal stays the only key-exposure surface, per
security-policy.md §10).
- The 201/409 status-code split STAYS for now because the byte-
identical fix (return 202 always + email-verification flow) is a
breaking change to the dashboard signup-then-reveal-key flow + the
Playwright happy path; that's the v2 mitigation, deferred to a
follow-up PR.
Tests: 194 → 228 (+34 new):
- tests/email.test.ts (24) — sendMail unconfigured no-op,
configured envelope shape, replyTo handling, error returns,
verifySmtp success/failure, welcomeEmail + signupAttemptedNoticeEmail
shape + HTML-escape + Pramaan footer + no credentials-leak assertions
- tests/console-signup.test.ts (10) — fresh signup 201 + welcome
email queued + no API key in body, duplicate signup 409 + notice
email to legitimate holder + no tenant created + no tenant_id leak
in 409 body + scrypt-bound minimum response time (>= 10ms),
invalid-input 400s that skip both DB lookup and email send
Lint: 0 errors. Typecheck clean.
Production deploy pre-requisites:
1. Add 104.207.143.14 to Brevo Settings → SMTP & API → Authorized IPs
2. Add SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / EMAIL_FROM /
EMAIL_FROM_NAME to /opt/zeroauth/.env on the VPS
3. (Optional but recommended) Add SPF + DKIM + DMARC records on
zeroauth.dev via Hostinger:
- SPF: TXT @ "v=spf1 include:spf.brevo.com ~all"
- DKIM: TXT mail._domainkey <value from Brevo dashboard>
- DMARC: TXT _dmarc @ "v=DMARC1; p=quarantine; rua=mailto:dmarc@zeroauth.dev"
Without (1), every SMTP login returns 5.7.1 Unauthorized IP and
sendMail() returns { ok:false, error: 'Unauthorized IP' } — the
service still runs, just doesn't deliver. Without (3), Brevo-sent
mail may land in spam.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wires up the first real email infrastructure in the repo, AND closes the timing-oracle half of issue #27 (F-2 from PR #22 security review).
Architecture (ADR-0005)
```
src/services/
email.ts ← sendMail wrapper, never throws
│
└── nodemailer 8.0.7 ← MIT-0, generic SMTP
│
└── Brevo SMTP ← provider, swappable via .env
```
Picked nodemailer over emailjs + provider-specific SDKs (Postmark, SES, SendGrid). The four alternatives are surveyed in `adr/0005-adopt-nodemailer-for-smtp.md`. Generic SMTP keeps the provider choice in `.env` — Brevo today, easy to swap later.
What ships
Email infrastructure
F-2 partial mitigation (issue #27)
Closed half of the finding:
Still open:
The Medium severity drops to Low — there's still a status-code-based enumeration channel but it's slower, costs the attacker an email-delivery-rate-limit hit at Brevo per probe, and pings the legitimate user.
Test plan
Production deploy pre-reqs (operator action)
Three steps. Without #1 every SMTP login fails:
If #1 isn't done by the time deploy fires, the service still runs — `sendMail()` just logs `5.7.1 Unauthorized IP` and returns `{ok:false, error}`. The signup endpoints still 201/409 normally; emails just don't get delivered.
Security note for the operator
The SMTP credentials were shared in chat. Rotate the key from the Brevo dashboard (Settings → SMTP & API → "Replace" the SMTP key) after the VPS deploy is confirmed working. Then update `/opt/zeroauth/.env` on the VPS with the new key. Chat transcripts are not a secret-handling channel.
🤖 Generated with Claude Code