Appearance
Email
The server sends three kinds of email: sign-in links (registration and login), daily puzzle confirmations, and the daily puzzle itself. src/server/mail.js talks to each provider over plain fetch, with no SDK and no dependency. Adding a provider is about ten lines, and switching providers is an environment variable. Free tiers change, and when one stops being free, only MAIL_PROVIDER has to change.
Providers
bash
MAIL_PROVIDER=console # default: prints the mail to stdout, sends nothing
MAIL_PROVIDER=mailjet MAIL_API_KEY=<public> MAIL_API_SECRET=<private>
MAIL_PROVIDER=resend MAIL_API_KEY=re_...
MAIL_PROVIDER=brevo MAIL_API_KEY=xkeysib-...
MAIL_PROVIDER=mailgun MAIL_API_KEY=... MAIL_DOMAIN=mg.example.com
MAIL_PROVIDER=webhook MAIL_WEBHOOK=https://... # optional MAIL_API_KEY sent as a Bearer token
MAIL_FROM="gammonchain <no-reply@example.com>"
PUBLIC_URL=https://example.com| provider | "configured" when | free tier noted in the code / DEPLOY.md (August 2026) |
|---|---|---|
console | always | n/a |
mailjet | MAIL_API_KEY and MAIL_API_SECRET | 6,000/month, 200/day, multiple domains on one account |
resend | MAIL_API_KEY | about 3,000/month |
brevo | MAIL_API_KEY | about 300/day |
mailgun | MAIL_API_KEY and MAIL_DOMAIN | |
webhook | MAIL_WEBHOOK |
An unknown MAIL_PROVIDER fails with a message listing the valid names, not an obscure TypeError.
console counts as configured
With the default console provider, registration, login links and subscriptions are all enabled, and each "sent" email, sign-in links included, is printed to the server log. That is convenient locally. In public it means nobody receives their link. Set a real provider before you advertise accounts.
/healthz does not include mail status. emailStatus over the WebSocket reports available and provider to the client.
Mailjet
The key pair
Mailjet needs two credentials
Mailjet authenticates with a pair: the public API key as the HTTP Basic username and the private secret key as the password. Every other provider here takes a single token. So a Mailjet setup with only MAIL_API_KEY set looks correct and fails at the first send. The mailer treats Mailjet as unconfigured unless both MAIL_API_KEY (public) and MAIL_API_SECRET (private) are set, and refuses to send, rather than finding out halfway through a batch.
A 200 response can still be an error
Mailjet's Send API v3.1 returns per-message statuses in the response body. A bad sender address comes back as HTTP 200 with Status: "error" inside. Code that checks only res.ok counts that as sent and hides the one failure worth knowing about. _mailjet() reads Messages[0].Status and throws with the listed errors unless it is success.
Domain authentication
Mail does not work until the sending domain is authenticated. Mailjet accepts the API call and simply does not deliver, which looks exactly like "registration is broken", with nothing in the server log. Do this first:
- Add the domain. Mailjet → Account settings → Sender domains & addresses → Add a sender domain.
- Publish the DNS records it gives you. All of them matter:
- DKIM: a TXT record at a name Mailjet specifies. It signs your mail so receivers can tell it came from you. Without it, mail goes to spam.
- SPF: a TXT record on the domain that authorises Mailjet to send for it. If the domain already has one, merge them (below).
- DMARC:
_dmarc, TXT. Optional at first, but Gmail and Yahoo now require it for bulk senders, and the daily puzzle is bulk mail. Start withv=DMARC1; p=none;.
- Wait for validation. Minutes to an hour, though Mailjet says up to 48 hours. Do not skip ahead: an unauthenticated domain fails silently at the provider, not in your logs.
- Get the API key pair (Account settings → API key management) and set
MAIL_API_KEYandMAIL_API_SECRET. MAIL_FROMmust use the authenticated domain, for examplegammonchain <no-reply@gammonchain.com>.
Over the daily limit, Mailjet queues messages, but only for 3 days, and then discards them. A sustained overrun loses mail without any error. MAIL_DAILY_CAP defaults to 500. deploy/.env.example, docker-compose.prod.yml and the committed platform configs set it to 200 to match Mailjet's free tier.
SPF with Zoho Mail (or any existing sender)
Two providers on one domain coexist fine, except for SPF:
| record | owned by | note |
|---|---|---|
| MX | Zoho only | Mailjet does not receive mail |
| DKIM | both | different selectors, so no conflict |
| SPF | shared: exactly one record | see below |
| DMARC | one record | covers both |
A domain may publish only one SPF record
A second SPF record does not mean "both apply". RFC 7208 makes it a permerror, and receivers then treat the domain as having no SPF. So adding Mailjet's record next to Zoho's breaks the mail that was already working. Replacing Zoho's record breaks it the other way. The two must be merged:
v=spf1 include:zoho.eu include:spf.mailjet.com ~allUse include:zoho.com, zoho.eu or zoho.in to match your Zoho region.
bin/setup-dns.js
Publishing DKIM and SPF by hand means copying long base64 values between two dashboards, and one typo fails silently. bin/setup-dns.js reads the records the mail provider wants and publishes them to Cloudflare DNS.
bash
export MAILJET_API_KEY=... MAILJET_API_SECRET=... # default provider
export CLOUDFLARE_API_TOKEN=... # scope: Zone -> DNS -> Edit, this zone only
node bin/setup-dns.js --domain gammonchain.com --dry-run # show, change nothing
node bin/setup-dns.js --domain gammonchain.com --verify # publish, then ask the provider to re-check
# or Resend
export RESEND_API_KEY=... # needs domain access, not just sending
node bin/setup-dns.js --domain gammonchain.com --provider resendHow it behaves:
- Mailjet: the domain must already be added in Mailjet. The script publishes records but does not register the domain. It maps only fields it recognises in Mailjet's
/v3/REST/dnsresponse: the DKIM record name and value, the SPF value, and the ownership token. If it recognises nothing, it prints the raw payload and stops instead of guessing, because a wrong DKIM value fails silently at the provider and looks identical to "not set up yet". - Resend: if the domain is not registered with Resend, the script creates it (except with
--dry-run). - Only an SPF record is treated as SPF. An existing TXT record counts as the SPF record only if its content starts with
v=spf1; other TXT records at the apex (site-verification tokens and the like) are never updated or overwritten — a new record is created beside them. Below the apex (a DKIM selector name), the single non-SPF TXT at that name is updated in place when the value changed. - SPF is merged, never replaced. If an SPF record already exists, the mechanisms are combined with
mergeSpf(), and the existingallqualifier is kept, because a merge must never loosen a policy you chose.--dry-runshows the before and after. - Idempotent. Records that are already correct are left alone. Re-running adds nothing.
--verifyasks the provider to re-check afterwards. "Not verified yet" straight after publishing is normal.- It never prints either token, not even truncated.
- It does not publish a DMARC record. Add
_dmarcyourself.
--dry-run still shows every planned + (create) and ~ (update) before anything is written.
Checking it end to end
Every step above fails quietly, so test the whole chain: register a real account on the deployed site. If the link arrives and signs you in, it all works. If it does not, check the provider's own dashboard log first, because the server only knows whether the API call was accepted.
How sending behaves
- Sign-in links (
loginMail) work once and expire after 30 minutes. Each key and each email address can trigger at most 5 emails per hour. - Double opt-in: a subscription sends one confirmation email. Nothing else goes to that address until the link is clicked.
- Daily puzzle: an in-process scheduler checks every 5 minutes and runs once when the UTC hour equals
MAIL_HOUR(default 8). It needs no platform features, but it only runs while the process is alive. Hosts with real cron can callPOST /api/cron/daily?key=$CRON_KEYinstead. Both paths are idempotent per address per day (last_sent). - Each run mails at most
MAIL_DAILY_CAPaddresses, with 3 concurrent sends. It stops after 5 failures, so a broken key does not burn the quota one bounce at a time. Only messages that actually went out are marked as sent. - Every puzzle email has a one-click unsubscribe link and a
List-Unsubscribeheader. - Each HTTP call to a provider times out after 15 seconds.
- Full addresses never appear in logs. They are redacted to
f***@example.com.