---
id: server-architecture/012
title: Set up Resend for real account emails
epic: server-architecture
state: needs-input
priority: 2
blocked_by: []
estimate: S
created: 2026-08-12
updated: 2026-08-13
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
---

## What this is

Task 010 (real accounts) is deployed and live — see `land-of-lor/docs/handoff/historic/010-accounts.md`.
Its mailer (`director/src/email.ts`) already knows how to send through
[Resend](https://resend.com); until real credentials exist it falls back to
`LogMailer`, which just writes the verify/reset link to the director's own log
instead of emailing it. **This is non-blocking** — accounts, guest play, and
link-code device attach all work today without this. It only matters for two
things: a real "verify your email" message, and self-service password reset
(right now, a password reset can only be completed by someone reading the
director's server log for the link).

This task is **entirely external-repo actions only Kris can do** — creating a
Resend account, proving domain ownership via DNS, and generating an API key —
so it's filed straight to `needs-input` rather than `backlog`. No design
decision is open here (see "Why these exact values" below); it's a checklist.

## Step by step

**1. Create a Resend account** — go to [resend.com](https://resend.com), sign
up (free tier is enough: 3,000 emails/month, 100/day — nowhere near what this
game needs any time soon). Use whatever email/login Kris normally uses for
project services.

**2. Add and verify the sending domain** — in the Resend dashboard, go to
**Domains → Add Domain**. Enter exactly:

```
mail.lor-server.cocreations.com.au
```

(a dedicated subdomain of the game's own subdomain — not the bare
`cocreations.com.au` root, which is the CoCreations business domain and
already carries its own MX record; keeping the sending domain scoped under
`lor-server.` means this can't touch or break any existing business email
setup, and it's exactly what Resend itself recommends: a dedicated
subdomain for transactional mail).

Resend will display a set of DNS records to add (typically an MX record plus
2-3 TXT/DKIM records, and it'll suggest a DMARC TXT too — take that
suggestion). **Add exactly what Resend's dashboard shows you** — the values
are generated per-domain at add-time, so there's nothing to copy from this
task file. `lor-server.cocreations.com.au`'s DNS (and therefore this new
`mail.` subdomain's) is hosted by iFastNet (nameservers `ns101.ifastnet.com` /
`ns201.ifastnet.com` — confirmed live 2026-08-12), so add the records there,
in whatever iFastNet/cPanel-style DNS zone editor Kris already uses to manage
`cocreations.com.au`.

Resend auto-checks verification in the background; it's usually minutes, can
be a few hours depending on DNS propagation. The domain shows **Verified** in
the dashboard once it's done — don't move on to step 4 (dropping the API key
onto the droplet) until it does, since Resend will reject sends from an
unverified domain.

**3. Generate an API key** — **API Keys → Create API Key**. Name it something
identifiable, e.g. `lor-director-prod`. Permission: **Sending access** only
(not "Full access" — the director only ever calls the send-email endpoint,
per `director/src/email.ts`'s `ResendMailer`). Copy the key immediately —
Resend only shows it once (`re_...`).

**4. Drop the three env vars onto the droplet** — SSH to the droplet
(`ssh lor@209.38.29.22`), then:

```bash
sudo nano /etc/lor/game.env
```

Add these three lines (the file already has `DATABASE_URL`, `DIRECTOR_PORT`,
`SNAPSHOT_INTERVAL_MS`, `GODOT_BIN` — confirmed live 2026-08-12; add these
alongside, don't replace anything):

```
RESEND_API_KEY=re_your_actual_key_from_step_3
EMAIL_FROM="Legend of Rah <noreply@mail.lor-server.cocreations.com.au>"
PUBLIC_BASE_URL=https://lor-server.cocreations.com.au/director
```

Save, then restart the director so it picks up the new env:

```bash
sudo systemctl restart lor-director.service
sudo systemctl status lor-director.service   # confirm active (running), no crash-loop
```

**5. Verify it's actually sending** — trigger `attach_email` or
`request_password_reset` against the live director (e.g.
`npm run verify-accounts-e2e` from `game/director/`, or by hand from a
client) and confirm two things: (a) `journalctl -u lor-director -f` no longer
logs a `[mailer:log-fallback]` line for that request, and (b) the email
actually lands in the test inbox used.

## Why these exact values

- **`mail.lor-server.cocreations.com.au` as the sending domain, not the bare
  `cocreations.com.au` root**: confirmed 2026-08-12 that the root domain
  already carries an MX record (self-pointed, `0 cocreations.com.au.` — no
  active third-party mail provider visible, but still Kris's real business
  domain). Resend's own guidance is to use a dedicated subdomain for
  transactional/marketing mail regardless, so the sending domain's DNS is
  fully isolated from anything else on `cocreations.com.au`.
- **`PUBLIC_BASE_URL=https://lor-server.cocreations.com.au/director`, with
  the `/director` suffix**: confirmed against the droplet's live Caddyfile
  (2026-08-12) — `lor-server.cocreations.com.au` only reverse-proxies paths
  under `/director/*` to the director process; everything else either hits
  `/play/*` (a static file server) or falls through to a placeholder
  `"Lor game server is up."` response. `email.ts`'s `buildVerifyUrl`/
  `buildResetUrl` build `${PUBLIC_BASE_URL}/verify?token=...` and
  `/reset?token=...`; `http.ts`'s route matcher (`matchesPath`) expects
  either a bare `/verify` or a `/director`-prefixed `/director/verify` — so
  the value here needs the `/director` suffix for the emailed link to
  actually reach the handler through Caddy instead of 404ing.

## Flagged, not fixed here: the link-code `/play` URL breaks either way

`server.ts`'s `create_link_code` handler builds its QR/URL field the same way:
`${PUBLIC_BASE_URL}/play?link=<code>`. With the `/director`-suffixed value
above, that becomes `.../director/play?link=...` — not a route Caddy or the
director serves, so **the QR code / link-code URL will be broken** once this
env var is set (today, with `PUBLIC_BASE_URL` unset, it falls back to
`http://localhost:8081/play?link=...`, which is equally unusable off the
droplet — so this isn't a regression, just not fixed by this task either).

This isn't fixable by choosing a different single value: verify/reset need
the director's own domain+prefix; the play link needs wherever the *actual*
current web client is hosted, which per `land-of-lor/docs/handoff/historic/010-accounts.md`'s
Deploy section is `https://lor.kris.ai-task-runner.com/play/` (the workspace
site) — **not** `lor-server.cocreations.com.au/play/`, which Caddy still
points at an old `pocs/multiplayer-poc` web build that was never updated in
the accounts-v2 deploy (confirmed live 2026-08-12: still serving from
`/opt/lor/game-server/pocs/multiplayer-poc/world-server/client/web-build`).
One env var can't correctly serve both purposes under the current route
layout. Not blocking — a link code can also be typed in by hand on the
account screen (`redeem_link_code`), so the QR/URL is a convenience, not the
only path — but worth a real follow-up: either split into two env vars
(e.g. a `DIRECTOR_PUBLIC_URL` for verify/reset and a separate one for the
play link), or update the droplet's own `/play` to reverse-proxy or mirror
the current build instead of serving the stale `pocs/` one. Prioritized
verify/reset (the security-relevant, actually-requested piece) over the QR
link in the values recommended above.

## Progress — 2026-08-13 (steps 1, 3 and 4 done; step 2 is the whole remainder)

Kris created the Resend account and generated the API key (sending-access only
— confirmed by the key being rejected for `GET /domains`, which is exactly the
scope this task asked for). The domain `mail.lor-server.cocreations.com.au` is
**added but not verified**: the dashboard is sitting on "Fill in your DNS
Records", and a live DNS check found no TXT, MX or DKIM record published under
that subdomain.

**The key is on the droplet but commented out, on purpose.** Deploying it live
and running `verify-accounts-e2e` showed why: `createMailer` only falls back to
`LogMailer` when `RESEND_API_KEY` is *absent*, so with the key set and the
domain unverified, every send throws and `attach_email` answers
`INTERNAL_ERROR` — strictly worse for real users than the log fallback. A
direct API send confirmed the cause (`403 — the … domain is not verified`).
`/etc/lor/game.env` therefore has the key present, commented, with a note; the
last step of this task is to uncomment it and re-run `game/update_game_env.sh`.
(A `422 Invalid to field` seen alongside this was just the e2e's
`@example.com` test recipient, not a config problem.)

`EMAIL_FROM` is live. `PUBLIC_BASE_URL` was superseded the same day by
`EMAIL_VERIFY_BASE_URL` + `LINK_CODE_BASE_URL` — see task 016, which fixes the
broken-QR-link consequence flagged below.

### The remaining work is DNS, and it is at iFastNet

The records must be added in **iFastNet's** DNS zone editor: `cocreations.com.au`
delegates to `ns101.ifastnet.com` / `ns201.ifastnet.com` (re-confirmed
2026-08-13), so whoever registers the domain does not control its records. A
Namecheap API key was tried and cannot do this job for two independent reasons:
the credentials were rejected outright (`1011102 — API Key is invalid or API
access has not been enabled`; Namecheap also requires API access switched on
per-account and the calling IP whitelisted), and more fundamentally Namecheap's
`domains.dns.setHosts` only edits zones served by Namecheap's own BasicDNS —
it has no effect on a domain delegated elsewhere. Moving the nameservers to
Namecheap to make the API usable would mean re-creating the entire existing
zone by hand, including the business domain's live MX and web records, to fix
one subdomain: not worth the blast radius.

So step 2 stays a human, in-browser action in iFastNet's control panel.

## Definition of done

- Resend domain `mail.lor-server.cocreations.com.au` shows **Verified** in
  the Resend dashboard.
- `/etc/lor/game.env` on the droplet has all three vars, `lor-director.service`
  restarted clean.
- A live `attach_email` or `request_password_reset` produces a real email
  (not a log line) in the test inbox used to check.