Skip to content

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" whenfree tier noted in the code / DEPLOY.md (August 2026)
consolealwaysn/a
mailjetMAIL_API_KEY and MAIL_API_SECRET6,000/month, 200/day, multiple domains on one account
resendMAIL_API_KEYabout 3,000/month
brevoMAIL_API_KEYabout 300/day
mailgunMAIL_API_KEY and MAIL_DOMAIN
webhookMAIL_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:

  1. Add the domain. Mailjet → Account settings → Sender domains & addresses → Add a sender domain.
  2. 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 with v=DMARC1; p=none;.
  3. 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.
  4. Get the API key pair (Account settings → API key management) and set MAIL_API_KEY and MAIL_API_SECRET.
  5. MAIL_FROM must use the authenticated domain, for example gammonchain <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:

recordowned bynote
MXZoho onlyMailjet does not receive mail
DKIMbothdifferent selectors, so no conflict
SPFshared: exactly one recordsee below
DMARCone recordcovers 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 ~all

Use 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 resend

How 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/dns response: 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 existing all qualifier is kept, because a merge must never loosen a policy you chose. --dry-run shows the before and after.
  • Idempotent. Records that are already correct are left alone. Re-running adds nothing.
  • --verify asks 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 _dmarc yourself.

--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 call POST /api/cron/daily?key=$CRON_KEY instead. Both paths are idempotent per address per day (last_sent).
  • Each run mails at most MAIL_DAILY_CAP addresses, 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-Unsubscribe header.
  • Each HTTP call to a provider times out after 15 seconds.
  • Full addresses never appear in logs. They are redacted to f***@example.com.

Rules engine, fairness protocol, verifier, analysis and worker: MIT. Server and client: AGPL-3.0-or-later.