# Console Invite Delivery Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Account invites reach the invitee: by Keymail federation when
they have a Keymail mailbox, by email through the platform's own mail
layer when not, and as a hand-off link only when both fail.

**Architecture:** `Keymail.SendFederated` speaks Keymail's federation
protocol (lookup, challenge, proof of work, register, deliver) as
`console@<origin host>`, using the console's existing signing key.
`ConsoleMailer` owns one SES identity created through `mail.EnsureDomain`
under the ops account `bab`, and sends with SESv2 `SendEmail` on the
console's own AWS role. `handleInvite` tries them in that order.

**Tech Stack:** Go 1.25 stdlib (crypto/ecdh, crypto/ecdsa, crypto/sha256,
net/http), aws-sdk-go-v2 `service/sesv2` v1.66.6 (already in go.mod),
OpenTofu for `infra/modules/region-host`.

**Spec:** `docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md`

## Global Constraints

- Work in the worktree `.claude/worktrees/console-invite-delivery` on branch
  `design-console-invite-delivery`; never `git checkout -- <file>` and never
  bare `git stash`.
- Go builds from this sandbox need `export GOCACHE=$TMPDIR/gocache` in the
  same command; socket tests need `TMPDIR=/tmp/g` (see AGENTS.md).
- Every user-facing string (flashes, message subject and body) is approved
  by Paul through the copy-review skill before it is written into a file.
  Approved text replaces the drafts in this plan verbatim. No em dashes in
  new copy unless Paul's approved text has them.
- Keymail protocol constants are copied from the Keymail source with a
  comment naming the file: `attestContext = "keymail-attest-v1"`
  (`keymail/crypto.go:35`), the pow hash `SHA-256(nonce || sign_pub_raw ||
  counter_be64)` with `nonce = SHA-256(challenge_token)`
  (`keymail/internal/pow`, `keymail/federation.go` handleRegister).
- `maxFederationPowBits = 24`.
- Federation From address: `console@` + `OriginHost(origin)`, the same From
  `SendApp` uses.
- The console's mail identity lives under account `release.OpsAccount`
  (`"bab"`) with referrer app name `console-mail`, which `claimApp` refuses
  in `bab`.
- From resolution: `CARLOS_CONSOLE_MAIL_FROM` if set; else
  `noreply@console.bab.<CARLOS_MAIL_PLATFORM_SUFFIX>` if the suffix is set;
  else no email fallback. No email fallback either when
  `cfg.MailProvisioner` is nil.
- `handleInvite` uses one 20s context for the whole delivery attempt.
- darwin and windows cross-builds stay green (`GOOS=darwin go build ./...`,
  `GOOS=windows go build ./...`).

## Review Focus

1. **The console's signing key changed since it registered with a
   mailbox.** Keymail answers 403 "signing key does not match registered
   key" rather than `unknown_sender`. A person expects the invite to go
   through anyway. `SendFederated` treats that 403 like `unknown_sender`:
   register (full price) and retry once. Test in Task 2.
2. **An invitee's mailbox exists but is unclaimed** (409 "inbox not yet
   claimed" from lookup). A person expects the email fallback. The lookup
   failure maps to `ErrNoKeymailMailbox` whatever the status. Test in Task 2.
3. **Keymail is down or slow.** A person expects the page to come back
   inside the timeout with the email or hand-off result, not hang. The
   shared 20s context bounds both legs. Task 4 tests a Keymail that never
   answers within a short test timeout, with email still tried.
4. **The identity is not verified yet** (fresh deployment, DNS still
   converging). A person expects the hand-off link, not a 500. `Send`
   returns `ErrConsoleMailNotReady`, `handleInvite` falls through. Tests in
   Tasks 3 and 4.
5. **Someone creates an app named `console-mail` in `bab` and deletes
   it.** A person expects the console's mail identity to survive. Creation
   is refused at `claimApp`. Test in Task 3.

---

## File Structure

- Create `internal/console/keymailpow.go`: pow mine and verify, ported from
  Keymail. One responsibility, easy to test alone.
- Create `internal/console/keymailfed.go`: `SendFederated`, sender
  registration, `attestationMsg`, the typed Keymail error.
- Modify `internal/console/keymail.go`: `do()` returns `*keymailError`;
  `SendApp`'s seal-and-sign moves into `sealEnvelope`, shared with
  federation.
- Create `internal/mail/sessend.go`: the narrow SESv2 send seam and its
  constructor. It sits next to the provisioner's SES code.
- Create `internal/console/consolemail.go`: `ConsoleMailer`, From
  resolution, the SES-backed mailer with ensure/ready/send.
- Modify `internal/console/server.go`: Config field, Server fields,
  `claimApp` reservation, `handleInvite`.
- Modify `cmd/console/main.go`: read `CARLOS_CONSOLE_MAIL_FROM`, build the
  SES send client, start the ensure goroutine.
- Modify `infra/modules/region-host/{variables.tf,main.tf,README.md}`: the
  SES send grant.
- Tests: `keymailpow_test.go`, `keymailfed_test.go`, `consolemail_test.go`,
  `invite_delivery_test.go` (new), `keymail_test.go` (the fake),
  `server_account_test.go` (existing invite test), `cmd/console`
  (env parsing).

---

### Task 0: Copy review

**Files:** none until approved; strings land in Task 4.

- [ ] **Step 1: Run the copy-review skill** over these strings (context:
  the Team page flash after an owner invites someone, and the invite
  message itself, now also sent as plain email):

  1. `invite.flash.keymail`: "Invited {to}. The link is in their Keymail inbox."
  2. `invite.flash.email`: "Invited {to}. We emailed them the link."
  3. `invite.flash.failed`: "Invited {to}, but delivering the link failed — you can send it on yourself: {link}"
  4. `invite.subject`: "You've been invited to an account on the CARLOS platform"
  5. `invite.body`: "{inviter} invited you to the account \"{account}\" on the CARLOS platform.\n\nOpen this link while signed in as {to} to accept. It works once and expires in 7 days:\n\n{link}\n\nIf you weren't expecting this, ignore it — nothing happens without the link."

- [ ] **Step 2: Record the approved text** in
  `copy-review/strings.json` in the worktree and commit it:

```bash
git add copy-review/strings.json
git commit -m "copy: invite delivery strings, as approved"
```

Task 4 uses the approved text verbatim. Do not start Task 4 without it.

---

### Task 1: Proof of work

**Files:**
- Create: `internal/console/keymailpow.go`
- Test: `internal/console/keymailpow_test.go`

**Interfaces:**
- Produces: `const maxFederationPowBits = 24`;
  `func powVerify(nonce, signPub []byte, counter uint64, bits int) bool`;
  `func powMine(ctx context.Context, nonce, signPub []byte, bits int) (uint64, error)`.

- [ ] **Step 1: Write the failing test**

```go
package console

import (
	"context"
	"crypto/sha256"
	"encoding/binary"
	"errors"
	"math/bits"
	"testing"
	"time"
)

// refLeadingZeros is written out independently of keymailpow.go so the
// test does not check the code against itself.
func refLeadingZeros(h []byte) int {
	n := 0
	for _, b := range h {
		if b == 0 {
			n += 8
			continue
		}
		return n + bits.LeadingZeros8(b)
	}
	return n
}

func refHash(nonce, signPub []byte, counter uint64) []byte {
	h := sha256.New()
	h.Write(nonce)
	h.Write(signPub)
	var c [8]byte
	binary.BigEndian.PutUint64(c[:], counter)
	h.Write(c[:])
	return h.Sum(nil)
}

func TestPowMineFindsAVerifiableCounter(t *testing.T) {
	nonce := sha256.Sum256([]byte("challenge-token"))
	signPub := []byte("sign-pub-bytes")
	ctr, err := powMine(context.Background(), nonce[:], signPub, 12)
	if err != nil {
		t.Fatal(err)
	}
	if got := refLeadingZeros(refHash(nonce[:], signPub, ctr)); got < 12 {
		t.Fatalf("counter %d gives %d leading zero bits, want >= 12", ctr, got)
	}
	if !powVerify(nonce[:], signPub, ctr, 12) {
		t.Fatal("powVerify rejects the counter powMine found")
	}
	if powVerify(nonce[:], []byte("another key"), ctr, 12) && refLeadingZeros(refHash(nonce[:], []byte("another key"), ctr)) < 12 {
		t.Fatal("powVerify accepted a solution for a different key")
	}
}

func TestPowZeroBitsIsFree(t *testing.T) {
	if !powVerify(nil, nil, 0, 0) {
		t.Fatal("0 bits must verify")
	}
	ctr, err := powMine(context.Background(), nil, nil, 0)
	if err != nil || ctr != 0 {
		t.Fatalf("powMine(0 bits) = %d, %v; want 0, nil", ctr, err)
	}
}

func TestPowMineHonoursCancellation(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()
	// 64 bits will never be found.
	_, err := powMine(ctx, []byte("n"), []byte("k"), 64)
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("powMine under a 20ms deadline = %v, want DeadlineExceeded", err)
	}
}
```

- [ ] **Step 2: Run it and watch it fail**

Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestPow' -count=1`
Expected: FAIL to compile, `undefined: powMine`.

- [ ] **Step 3: Implement**

```go
package console

// Proof of work for Keymail federation, ported from keymail
// internal/pow/pow.go and the nonce rule in federation.go handleRegister.
// An unknown sender pays it once per recipient mailbox to register its
// signing key; after that, deliveries from the same key are free.
//
//	nonce = SHA-256(challenge token)
//	find counter: SHA-256(nonce || sign_pub_raw || counter_be64) has >= bits leading zero bits

import (
	"context"
	"crypto/sha256"
	"encoding/binary"
	"math/bits"
)

// maxFederationPowBits is the highest price the console will pay. A mailbox
// sets its own price, and an invite runs inside an HTTP request: 24 bits is
// about sixteen million hashes, a few seconds on one core. Keymail's default
// is 20.
const maxFederationPowBits = 24

func powLeadingZeroBits(h []byte) int {
	n := 0
	for _, b := range h {
		if b == 0 {
			n += 8
			continue
		}
		return n + bits.LeadingZeros8(b)
	}
	return n
}

func powVerify(nonce, signPub []byte, counter uint64, difficulty int) bool {
	if difficulty <= 0 {
		return true
	}
	h := sha256.New()
	h.Write(nonce)
	h.Write(signPub)
	var c [8]byte
	binary.BigEndian.PutUint64(c[:], counter)
	h.Write(c[:])
	return powLeadingZeroBits(h.Sum(nil)) >= difficulty
}

// powMine searches counters from 0 on one goroutine, checking ctx every
// 65536 attempts. One core is enough at the prices the console accepts.
func powMine(ctx context.Context, nonce, signPub []byte, difficulty int) (uint64, error) {
	if difficulty <= 0 {
		return 0, nil
	}
	prefix := make([]byte, 0, len(nonce)+len(signPub)+8)
	prefix = append(append(prefix, nonce...), signPub...)
	buf := append(prefix, make([]byte, 8)...)
	n := len(prefix)
	for counter := uint64(0); ; counter++ {
		if counter&0xffff == 0 {
			if err := ctx.Err(); err != nil {
				return 0, err
			}
		}
		binary.BigEndian.PutUint64(buf[n:], counter)
		sum := sha256.Sum256(buf)
		if powLeadingZeroBits(sum[:]) >= difficulty {
			return counter, nil
		}
	}
}
```

- [ ] **Step 4: Run the tests**

Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestPow' -count=1`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add internal/console/keymailpow.go internal/console/keymailpow_test.go
git commit -m "console: keymail federation proof of work"
```

---

### Task 2: `Keymail.SendFederated` and the federation fake

**Files:**
- Create: `internal/console/keymailfed.go`
- Modify: `internal/console/keymail.go` (`do`, `SendApp`)
- Modify: `internal/console/keymail_test.go` (the fake)
- Test: `internal/console/keymailfed_test.go`

**Interfaces:**
- Consumes: `powMine`, `maxFederationPowBits` (Task 1); existing
  `sealToBox`, `signRaw`, `envelopeMsg`, `OriginHost`, `envelope`.
- Produces:
  - `var ErrNoKeymailMailbox error`
  - `var ErrFederationPriceTooHigh error`
  - `func (k *Keymail) SendFederated(ctx context.Context, to, subject, body string) (msgID string, err error)`
  - `type keymailError struct { Status int; Method, Path, Msg string }`
    with `Error() string`. `do()` returns it for every non-200 answer.
  - `func (k *Keymail) sealEnvelope(from, to, boxPub, subject, body string) (envelope, error)`
  - Fake additions: `fakeKeymail.fedSends []sentMessage` (Kind
    `"federation"`), `fakeKeymail.powBits int` (default 8),
    `fakeKeymail.noMailbox map[string]bool`, `fakeKeymail.blocked
    map[string]bool`, `fakeKeymail.senders map[string]string` (from →
    sign_pub), `fakeKeymail.registrations int`,
    `func (f *fakeKeymail) fedBodies() []string`.

- [ ] **Step 1: Make `do()` return a typed error.** In `keymail.go`,
  replace the non-200 branch so the message text stays byte-identical:

```go
// keymailError is a non-200 answer from Keymail. Error() keeps the text
// do() has always produced; Status and Msg let callers classify without
// matching strings.
type keymailError struct {
	Status       int
	Method, Path string
	Msg          string // Keymail's {"error": ...}, or the HTTP status line
}

func (e *keymailError) Error() string {
	return fmt.Sprintf("keymail: %s %s: %s", e.Method, e.Path, e.Msg)
}
```

and in `do()`:

```go
	if res.StatusCode != http.StatusOK {
		var e struct {
			Error string `json:"error"`
		}
		msg := res.Status
		if json.Unmarshal(raw, &e) == nil && e.Error != "" {
			msg = e.Error
		}
		return res.StatusCode, &keymailError{Status: res.StatusCode, Method: method, Path: path, Msg: msg}
	}
```

- [ ] **Step 2: Extract `sealEnvelope`** from `SendApp` (same file), and
  make `SendApp` call it. Behaviour is unchanged:

```go
// sealEnvelope seals {subject, body} to the recipient's box key and signs
// the envelope with the console's key. SendApp and SendFederated share it,
// so the two wires cannot drift apart.
func (k *Keymail) sealEnvelope(from, to, boxPub, subject, body string) (envelope, error) {
	payload, err := json.Marshal(map[string]string{"subject": subject, "body": body})
	if err != nil {
		return envelope{}, err
	}
	blob, err := sealToBox(boxPub, payload)
	if err != nil {
		return envelope{}, fmt.Errorf("keymail: seal to %s: %w", to, err)
	}
	idb := make([]byte, 16)
	if _, err := rand.Read(idb); err != nil {
		return envelope{}, err
	}
	id := hex.EncodeToString(idb)
	date := time.Now().UTC().Format(time.RFC3339)
	sig, err := signRaw(k.signKey, envelopeMsg(id, from, to, date, blob))
	if err != nil {
		return envelope{}, err
	}
	return envelope{
		V: protocolVersion, ID: id, From: from, To: to, Date: date,
		SignPub: k.SignPub(), Blob: blob, Sig: sig,
	}, nil
}
```

`SendApp` body after the lookup becomes:

```go
	env, err := k.sealEnvelope(from, to, lr.BoxPub, subject, body)
	if err != nil {
		return "", err
	}
	status, err := k.do(ctx, http.MethodPost, "/api/app/send", token,
		map[string]any{"envelope": env, "kind": "app"}, nil)
	if status == http.StatusUnauthorized {
		return "", ErrGrantRevoked
	}
	if err != nil {
		return "", err
	}
	return env.ID, nil
```

Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -count=1 -run 'Keymail|SendApp|Signin|Callback|StepUp'`
Expected: PASS (pure refactor).

- [ ] **Step 3: Extend the fake** in `keymail_test.go`. Add the fields
  listed under Interfaces, initialise the maps and `powBits: 8` in
  `newFakeKeymail`, and register the routes:

```go
	mux.HandleFunc("POST /api/federation/challenge", f.handleChallenge)
	mux.HandleFunc("POST /api/federation/register", f.handleRegister)
	mux.HandleFunc("POST /api/federation/deliver", f.handleDeliver)
```

Replace `handleLookup` so it routes like the relay (`keymail/relay.go`
`proxyAPI`) and prices like `federation.go` `handleLookup`:

```go
func (f *fakeKeymail) handleLookup(w http.ResponseWriter, r *http.Request) {
	f.mu.Lock()
	defer f.mu.Unlock()
	addr := r.URL.Query().Get("addr")
	local, host, ok := strings.Cut(addr, "@")
	if !ok || host != "keymail.dev" {
		jsonErr(w, http.StatusBadRequest, "could not determine target mailbox")
		return
	}
	if f.noMailbox[local] {
		jsonErr(w, http.StatusNotFound, "no such mailbox")
		return
	}
	bits := f.powBits
	if _, known := f.senders[r.URL.Query().Get("from")]; known {
		bits = 0 // keymail prices a known sender at 0 whatever key it brings
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"addr":     addr,
		"sign_pub": "",
		"box_pub":  base64.StdEncoding.EncodeToString(f.boxPriv.PublicKey().Bytes()),
		"pow_bits": bits,
	})
}
```

Add the three verbs. Challenges are tracked in a map keyed by token. The
real Keymail seals them instead, but the wire is the same:

```go
type fakeChallenge struct {
	to, from, signPub string
	bits              int
}

func (f *fakeKeymail) handleChallenge(w http.ResponseWriter, r *http.Request) {
	f.mu.Lock()
	defer f.mu.Unlock()
	var req struct{ To, From, SignPub string }
	var raw struct {
		To      string `json:"to"`
		From    string `json:"from"`
		SignPub string `json:"sign_pub"`
	}
	if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
		jsonErr(w, http.StatusBadRequest, "bad json")
		return
	}
	req.To, req.From, req.SignPub = raw.To, raw.From, raw.SignPub
	if f.blocked[req.From] {
		jsonErr(w, http.StatusForbidden, "blocked")
		return
	}
	bits := f.powBits
	if f.senders[req.From] == req.SignPub {
		bits = 0
	}
	tok := make([]byte, 16)
	rand.Read(tok)
	token := base64.RawURLEncoding.EncodeToString(tok)
	if f.challenges == nil {
		f.challenges = map[string]fakeChallenge{}
	}
	f.challenges[token] = fakeChallenge{to: req.To, from: req.From, signPub: req.SignPub, bits: bits}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"challenge": token, "bits": bits, "expires_at": time.Now().Add(time.Minute).Unix()})
}

func (f *fakeKeymail) handleRegister(w http.ResponseWriter, r *http.Request) {
	f.mu.Lock()
	defer f.mu.Unlock()
	var req struct {
		To          string `json:"to"`
		Challenge   string `json:"challenge"`
		Counter     uint64 `json:"counter"`
		From        string `json:"from"`
		SignPub     string `json:"sign_pub"`
		BoxPub      string `json:"box_pub"`
		Attestation string `json:"attestation"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonErr(w, http.StatusBadRequest, "bad json")
		return
	}
	c, ok := f.challenges[req.Challenge]
	if !ok {
		jsonErr(w, http.StatusBadRequest, "bad challenge")
		return
	}
	delete(f.challenges, req.Challenge)
	if c.from != req.From || c.signPub != req.SignPub {
		jsonErr(w, http.StatusBadRequest, "challenge does not match this sender")
		return
	}
	nonce := sha256.Sum256([]byte(req.Challenge))
	signPubRaw, _ := base64.StdEncoding.DecodeString(req.SignPub)
	if refLeadingZeros(refHash(nonce[:], signPubRaw, req.Counter)) < c.bits {
		jsonErr(w, http.StatusForbidden, "proof of work does not meet difficulty")
		return
	}
	attest := []byte("keymail-attest-v1\n" + req.From + "\n" + req.SignPub + "\n" + req.BoxPub)
	if !testVerify(req.SignPub, attest, req.Attestation) {
		jsonErr(w, http.StatusForbidden, "bad key attestation")
		return
	}
	f.senders[req.From] = req.SignPub
	f.registrations++
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"status": "proven"})
}

func (f *fakeKeymail) handleDeliver(w http.ResponseWriter, r *http.Request) {
	f.mu.Lock()
	defer f.mu.Unlock()
	var req struct {
		Envelope envelope `json:"envelope"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonErr(w, http.StatusBadRequest, "bad json")
		return
	}
	e := req.Envelope
	if f.blocked[e.From] {
		jsonErr(w, http.StatusForbidden, "blocked")
		return
	}
	reg, known := f.senders[e.From]
	if !known {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusForbidden)
		json.NewEncoder(w).Encode(map[string]any{"error": "unknown_sender", "pow_bits": f.powBits})
		return
	}
	if reg != e.SignPub {
		jsonErr(w, http.StatusForbidden, "signing key does not match registered key")
		return
	}
	if e.V != "keymail1" || e.ID == "" || e.Date == "" ||
		!testVerify(e.SignPub, envelopeMsg(e.ID, e.From, e.To, e.Date, e.Blob), e.Sig) {
		jsonErr(w, http.StatusBadRequest, "bad envelope signature")
		return
	}
	plain, err := testOpen(f.boxPriv, e.Blob)
	if err != nil {
		jsonErr(w, http.StatusBadRequest, "blob does not open")
		return
	}
	var p struct{ Subject, Body string }
	json.Unmarshal(plain, &p)
	f.fedSends = append(f.fedSends, sentMessage{ID: e.ID, From: e.From, To: e.To, Subject: p.Subject, Body: p.Body, Kind: "federation"})
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"status": "delivered"})
}

func (f *fakeKeymail) fedBodies() []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	out := make([]string, 0, len(f.fedSends))
	for _, s := range f.fedSends {
		out = append(out, s.Body)
	}
	return out
}
```

Delete the throwaway `req struct{ To, From, SignPub string }` in
`handleChallenge` if the linter flags it; decoding into `raw` is enough.
`refLeadingZeros` and `refHash` come from `keymailpow_test.go` (same
package). Add a `challenges map[string]fakeChallenge` field.

- [ ] **Step 4: Write the failing tests** in `keymailfed_test.go`:

```go
package console

import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"errors"
	"strings"
	"testing"
)

func fedClient(t *testing.T, fk *fakeKeymail) *Keymail {
	t.Helper()
	k := NewKeymail(fk.srv.URL, "https://console.example.test")
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatal(err)
	}
	k.SetSigningKey(key)
	return k
}

func TestSendFederatedUnknownSenderRegistersThenDelivers(t *testing.T) {
	fk := newFakeKeymail(t, "paul@keymail.dev")
	k := fedClient(t, fk)
	id, err := k.SendFederated(context.Background(), "bill@keymail.dev", "subj", "the link")
	if err != nil {
		t.Fatal(err)
	}
	if fk.registrations != 1 {
		t.Fatalf("registrations = %d, want 1", fk.registrations)
	}
	if len(fk.fedSends) != 1 || fk.fedSends[0].ID != id || fk.fedSends[0].Body != "the link" ||
		fk.fedSends[0].From != "console@console.example.test" || fk.fedSends[0].To != "bill@keymail.dev" {
		t.Fatalf("fedSends = %+v", fk.fedSends)
	}
}

func TestSendFederatedKnownSenderPaysNothing(t *testing.T) {
	fk := newFakeKeymail(t, "paul@keymail.dev")
	k := fedClient(t, fk)
	ctx := context.Background()
	if _, err := k.SendFederated(ctx, "bill@keymail.dev", "s", "one"); err != nil {
		t.Fatal(err)
	}
	if _, err := k.SendFederated(ctx, "bill@keymail.dev", "s", "two"); err != nil {
		t.Fatal(err)
	}
	if fk.registrations != 1 {
		t.Fatalf("registrations = %d after two sends, want 1", fk.registrations)
	}
}

// Review Focus 1: the console's key changed since it registered.
func TestSendFederatedReRegistersARotatedKey(t *testing.T) {
	fk := newFakeKeymail(t, "paul@keymail.dev")
	fk.senders["console@console.example.test"] = "an-old-key"
	k := fedClient(t, fk)
	if _, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b"); err != nil {
		t.Fatal(err)
	}
	if fk.registrations != 1 || len(fk.fedSends) != 1 {
		t.Fatalf("registrations=%d fedSends=%d, want 1 and 1", fk.registrations, len(fk.fedSends))
	}
}

func TestSendFederatedRefusesAPriceAboveTheCap(t *testing.T) {
	fk := newFakeKeymail(t, "paul@keymail.dev")
	fk.powBits = maxFederationPowBits + 1
	k := fedClient(t, fk)
	_, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b")
	if !errors.Is(err, ErrFederationPriceTooHigh) {
		t.Fatalf("err = %v, want ErrFederationPriceTooHigh", err)
	}
	if fk.registrations != 0 {
		t.Fatal("registered despite refusing the price")
	}
}

// Review Focus 2 and the non-Keymail host.
func TestSendFederatedNoMailbox(t *testing.T) {
	fk := newFakeKeymail(t, "paul@keymail.dev")
	fk.noMailbox["ghost"] = true
	k := fedClient(t, fk)
	for _, to := range []string{"ghost@keymail.dev", "bill@gmail.com"} {
		_, err := k.SendFederated(context.Background(), to, "s", "b")
		if !errors.Is(err, ErrNoKeymailMailbox) {
			t.Errorf("SendFederated(%s) = %v, want ErrNoKeymailMailbox", to, err)
		}
	}
}

func TestSendFederatedBlocked(t *testing.T) {
	fk := newFakeKeymail(t, "paul@keymail.dev")
	fk.blocked["console@console.example.test"] = true
	k := fedClient(t, fk)
	_, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b")
	if err == nil || !strings.Contains(err.Error(), "blocked") {
		t.Fatalf("err = %v, want a blocked error", err)
	}
}

func TestSendFederatedWithoutKey(t *testing.T) {
	fk := newFakeKeymail(t, "paul@keymail.dev")
	k := NewKeymail(fk.srv.URL, "https://console.example.test")
	if _, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b"); err == nil {
		t.Fatal("SendFederated without a signing key succeeded")
	}
}
```

- [ ] **Step 5: Run and watch them fail**

Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestSendFederated' -count=1`
Expected: FAIL to compile, `k.SendFederated undefined`.

- [ ] **Step 6: Implement `keymailfed.go`**

```go
package console

// Delivery to someone who never granted the console anything: Keymail's
// federation protocol (keymail federation.go), the same one a Keymail
// instance uses to write to another. An unknown sender pays a proof of work
// to register its signing key with the recipient's mailbox, then delivers
// signed, sealed envelopes into the main inbox.
//
// The app API (SendApp) cannot do this: a grant delivers only to the user
// who approved it (keymail appapi.go handleAppSend), which is why invites
// sent through it never arrived.

import (
	"context"
	"crypto/ecdh"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"sync"
)

var (
	// ErrNoKeymailMailbox: Keymail has no mailbox for the address (not a
	// Keymail host, unknown local part, or a mailbox not yet claimed). The
	// caller should try another channel.
	ErrNoKeymailMailbox = errors.New("keymail: no mailbox for that address")
	// ErrFederationPriceTooHigh: the mailbox asks more proof of work than
	// maxFederationPowBits.
	ErrFederationPriceTooHigh = errors.New("keymail: the recipient's proof-of-work price is above the console's cap")
)

// attestContext is keymail crypto.go's attestation domain separator.
const attestContext = "keymail-attest-v1"

func attestationMsg(addr, signPub, boxPub string) []byte {
	return []byte(attestContext + "\n" + addr + "\n" + signPub + "\n" + boxPub)
}

// replyBox is the box key registration records for replies. Nobody replies
// to an invite and the console serves no Keymail endpoints, so the key is
// per process and its private half is dropped.
var (
	replyBoxOnce sync.Once
	replyBoxPub  string
	replyBoxErr  error
)

func federationReplyBox() (string, error) {
	replyBoxOnce.Do(func() {
		k, err := ecdh.P256().GenerateKey(rand.Reader)
		if err != nil {
			replyBoxErr = err
			return
		}
		replyBoxPub = base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
	})
	return replyBoxPub, replyBoxErr
}

// SendFederated delivers one sealed message to any Keymail mailbox as
// console@<origin host>. It registers the console's key with the mailbox
// first when Keymail asks for it.
func (k *Keymail) SendFederated(ctx context.Context, to, subject, body string) (string, error) {
	if k.signKey == nil {
		return "", errors.New("keymail: SendFederated requires a signing key")
	}
	from := "console@" + OriginHost(k.origin)
	var lr struct {
		BoxPub  string `json:"box_pub"`
		PowBits int    `json:"pow_bits"`
	}
	lu := "/api/federation/lookup?addr=" + url.QueryEscape(to) + "&from=" + url.QueryEscape(from)
	if _, err := k.do(ctx, http.MethodGet, lu, "", nil, &lr); err != nil {
		return "", fmt.Errorf("%w: %v", ErrNoKeymailMailbox, err)
	}
	if lr.PowBits > maxFederationPowBits {
		return "", ErrFederationPriceTooHigh
	}
	if lr.PowBits > 0 {
		if err := k.registerSender(ctx, to, from); err != nil {
			return "", err
		}
	}
	env, err := k.sealEnvelope(from, to, lr.BoxPub, subject, body)
	if err != nil {
		return "", err
	}
	err = k.deliver(ctx, env)
	if needsRegistration(err) {
		if rerr := k.registerSender(ctx, to, from); rerr != nil {
			return "", rerr
		}
		err = k.deliver(ctx, env)
	}
	if err != nil {
		return "", err
	}
	return env.ID, nil
}

func (k *Keymail) deliver(ctx context.Context, env envelope) error {
	_, err := k.do(ctx, http.MethodPost, "/api/federation/deliver", "", map[string]any{"envelope": env}, nil)
	return err
}

// needsRegistration: the mailbox does not know this sender, or knows it
// under a different key (the console's key was rotated). Both are cured by
// registering, which upserts the key.
func needsRegistration(err error) bool {
	var ke *keymailError
	if !errors.As(err, &ke) || ke.Status != http.StatusForbidden {
		return false
	}
	return ke.Msg == "unknown_sender" || ke.Msg == "signing key does not match registered key"
}

func (k *Keymail) registerSender(ctx context.Context, to, from string) error {
	signPub := k.SignPub()
	var ch struct {
		Challenge string `json:"challenge"`
		Bits      int    `json:"bits"`
	}
	if _, err := k.do(ctx, http.MethodPost, "/api/federation/challenge", "",
		map[string]string{"to": to, "from": from, "sign_pub": signPub}, &ch); err != nil {
		return err
	}
	if ch.Bits > maxFederationPowBits {
		return ErrFederationPriceTooHigh
	}
	signPubRaw, err := base64.StdEncoding.DecodeString(signPub)
	if err != nil {
		return err
	}
	nonce := sha256.Sum256([]byte(ch.Challenge))
	counter, err := powMine(ctx, nonce[:], signPubRaw, ch.Bits)
	if err != nil {
		return fmt.Errorf("keymail: proof of work: %w", err)
	}
	boxPub, err := federationReplyBox()
	if err != nil {
		return err
	}
	att, err := signRaw(k.signKey, attestationMsg(from, signPub, boxPub))
	if err != nil {
		return err
	}
	_, err = k.do(ctx, http.MethodPost, "/api/federation/register", "", map[string]any{
		"to": to, "challenge": ch.Challenge, "counter": counter, "from": from,
		"sign_pub": signPub, "box_pub": boxPub, "attestation": att,
	}, nil)
	return err
}
```

- [ ] **Step 7: Run the tests**

Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestSendFederated|TestPow|Keymail|SendApp' -count=1`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add internal/console/keymail.go internal/console/keymailfed.go internal/console/keymailfed_test.go internal/console/keymail_test.go
git commit -m "console: deliver to any Keymail mailbox by federation"
```

---

### Task 3: The console mailer

**Files:**
- Create: `internal/mail/sessend.go`
- Create: `internal/console/consolemail.go`
- Modify: `internal/console/server.go` (`claimApp`)
- Test: `internal/console/consolemail_test.go`

**Interfaces:**
- Consumes: `mail.EnsureDomain`, `mail.VerifyDomain`, `mail.TenantName`,
  `mail.StatusVerified`, `release.OpsAccount`, `release.Store`,
  `mail.Provisioner`, `dnsrec.Writer`, `mail.Deployment`.
- Produces:
  - `mail.EmailSender` interface: `SendEmail(ctx, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error)`
  - `func mail.NewSESEmailSender(ctx context.Context, region string) (mail.EmailSender, error)`
  - `type ConsoleMailer interface { Send(ctx context.Context, to, subject, body string) error }`
  - `var ErrConsoleMailNotReady error`
  - `const consoleMailApp = "console-mail"`
  - `func ConsoleMailFrom(explicit, platformSuffix string) string` (exported: `cmd/console` calls it in Task 5)
  - `func newSESConsoleMailer(from string, s release.Store, p mail.Provisioner, w dnsrec.Writer, d mail.Deployment, ses mail.EmailSender) *sesConsoleMailer`
  - `func (m *sesConsoleMailer) Ensure(ctx context.Context) error`
  - `func (m *sesConsoleMailer) Send(ctx context.Context, to, subject, body string) error`

- [ ] **Step 1: Write `internal/mail/sessend.go`** (a seam and a
  constructor, so no test of its own):

```go
package mail

import (
	"context"

	"github.com/aws/aws-sdk-go-v2/aws"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/sesv2"
	sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
)

// EmailSender is the one SESv2 call the console makes to send as itself
// (its invite fallback), narrowed so tests use a fake. Apps never use it:
// they send by SMTP with their own credential.
type EmailSender interface {
	SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error)
}

// NewSESEmailSender builds a sender on the default credential chain, which
// on a box is the host role.
func NewSESEmailSender(ctx context.Context, region string) (EmailSender, error) {
	cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
	if err != nil {
		return nil, err
	}
	return sesv2.NewFromConfig(cfg), nil
}

// SimpleEmailInput is a plain-text message attributed to tenant and, when
// set, counted through configSet.
func SimpleEmailInput(from, to, subject, body, tenant, configSet string) *sesv2.SendEmailInput {
	in := &sesv2.SendEmailInput{
		FromEmailAddress: aws.String(from),
		Destination:      &sesv2types.Destination{ToAddresses: []string{to}},
		Content: &sesv2types.EmailContent{Simple: &sesv2types.Message{
			Subject: &sesv2types.Content{Data: aws.String(subject), Charset: aws.String("UTF-8")},
			Body:    &sesv2types.Body{Text: &sesv2types.Content{Data: aws.String(body), Charset: aws.String("UTF-8")}},
		}},
		TenantName: aws.String(tenant),
	}
	if configSet != "" {
		in.ConfigurationSetName = aws.String(configSet)
	}
	return in
}
```

Run: `export GOCACHE=$TMPDIR/gocache; go build ./internal/mail`
Expected: builds.

- [ ] **Step 2: Write the failing tests** in `consolemail_test.go`:

```go
package console

import (
	"context"
	"errors"
	"testing"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/sesv2"

	"github.com/carlosframework/platform/internal/dnsrec"
	"github.com/carlosframework/platform/internal/mail"
	"github.com/carlosframework/platform/internal/release"
)

type fakeSES struct {
	sent []*sesv2.SendEmailInput
	err  error
}

func (f *fakeSES) SendEmail(_ context.Context, in *sesv2.SendEmailInput, _ ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) {
	if f.err != nil {
		return nil, f.err
	}
	f.sent = append(f.sent, in)
	return &sesv2.SendEmailOutput{MessageId: aws.String("m-1")}, nil
}

var verifiedStatus = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}

func testMailDeployment() mail.Deployment {
	return mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.test", SNSTopicARN: "arn:aws:sns:eu-west-1:1:t"}
}

func TestConsoleMailFrom(t *testing.T) {
	cases := []struct{ explicit, suffix, want string }{
		{"invites@tito.io", "oncarlos.test", "invites@tito.io"},
		{"", "oncarlos.test", "noreply@console.bab.oncarlos.test"},
		{"", "", ""},
		{"  Invites@Tito.io ", "", "invites@tito.io"},
	}
	for _, c := range cases {
		if got := ConsoleMailFrom(c.explicit, c.suffix); got != c.want {
			t.Errorf("ConsoleMailFrom(%q, %q) = %q, want %q", c.explicit, c.suffix, got, c.want)
		}
	}
}

func TestConsoleMailerSendsOnceVerified(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir())
	p := mail.NewMemProvisioner("1")
	p.StatusOnEnsure = verifiedStatus
	ses := &fakeSES{}
	m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
	if err := m.Ensure(ctx); err != nil {
		t.Fatal(err)
	}
	if err := m.Send(ctx, "bill@gmail.com", "subj", "body"); err != nil {
		t.Fatal(err)
	}
	if len(ses.sent) != 1 {
		t.Fatalf("sent %d, want 1", len(ses.sent))
	}
	in := ses.sent[0]
	if aws.ToString(in.FromEmailAddress) != "noreply@console.bab.oncarlos.test" ||
		in.Destination.ToAddresses[0] != "bill@gmail.com" ||
		aws.ToString(in.TenantName) != mail.TenantName(release.OpsAccount) ||
		aws.ToString(in.ConfigurationSetName) != mail.ConfigSetName(release.OpsAccount) {
		t.Fatalf("SendEmailInput = %+v", in)
	}
}

// Review Focus 4.
func TestConsoleMailerRefusesUntilVerified(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir())
	p := mail.NewMemProvisioner("1") // identities stay PENDING
	ses := &fakeSES{}
	m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
	if err := m.Send(ctx, "bill@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
		t.Fatalf("Send before verification = %v, want ErrConsoleMailNotReady", err)
	}
	if len(ses.sent) != 0 {
		t.Fatal("sent through an unverified identity")
	}
}

func TestConsoleMailerCachesAVerifiedIdentity(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir())
	p := mail.NewMemProvisioner("1")
	p.StatusOnEnsure = verifiedStatus
	ses := &fakeSES{}
	m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
	if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil {
		t.Fatal(err)
	}
	p.FailNext["IdentityStatus"] = errors.New("SES is down")
	if err := m.Send(ctx, "b@gmail.com", "s", "b"); err != nil {
		t.Fatalf("second send re-checked SES: %v", err)
	}
}

func TestConsoleMailerRechecksPendingAfterFiveMinutes(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir())
	p := mail.NewMemProvisioner("1")
	ses := &fakeSES{}
	m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
	now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
	m.now = func() time.Time { return now }
	if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
		t.Fatalf("first = %v", err)
	}
	for k := range p.Identities {
		p.Identities[k].Status = verifiedStatus
	}
	if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
		t.Fatalf("within 5 minutes = %v, want the cached not-ready", err)
	}
	now = now.Add(5*time.Minute + time.Second)
	if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil {
		t.Fatalf("after 5 minutes = %v, want sent", err)
	}
}

// Review Focus 5.
func TestConsoleMailAppIsReservedInOps(t *testing.T) {
	fx := newConsoleFixture(t)
	ops, err := fx.st.AccountByID(OpsAccountID)
	if err != nil {
		t.Fatal(err)
	}
	if got, _, _ := fx.srv.claimApp(context.Background(), ops, consoleMailApp, "paul@keymail.dev", "", true); got != claimReserved {
		t.Fatalf("claimApp(bab, %q) = %v, want claimReserved", consoleMailApp, got)
	}
}
```

`mail.ConfigSetName` and `mail.TenantName` both return `"carlos-" + acct`.
If `MemProvisioner.Identities` values or their `Status` field differ from
`memprovisioner.go`, follow that file.

- [ ] **Step 3: Run and watch them fail**

Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestConsoleMail' -count=1`
Expected: FAIL to compile, `undefined: newSESConsoleMailer`.

- [ ] **Step 4: Implement `consolemail.go`**

```go
package console

// The console's own email, for when Keymail cannot carry a message (today:
// invites to someone with no Keymail mailbox). The From identity is made by
// the platform's own mail layer, mail.EnsureDomain, owned by the ops
// account under the reserved referrer app consoleMailApp. Sends go out on
// the console's own AWS role, so no SMTP credential exists to store.

import (
	"context"
	"errors"
	"fmt"
	"log"
	"strings"
	"sync"
	"time"

	"github.com/carlosframework/platform/internal/dnsrec"
	"github.com/carlosframework/platform/internal/mail"
	"github.com/carlosframework/platform/internal/release"
)

// consoleMailApp is the referrer name on the console's domain record. claimApp
// refuses it in the ops account, so no app delete can tear the identity down.
const consoleMailApp = "console-mail"

// consoleMailPendingRecheck is how long a not-yet-verified answer is trusted.
const consoleMailPendingRecheck = 5 * time.Minute

// ErrConsoleMailNotReady: the identity is not verified (or could not be
// made). The caller falls back to the next channel.
var ErrConsoleMailNotReady = errors.New("console mail: sending identity not verified yet")

type ConsoleMailer interface {
	Send(ctx context.Context, to, subject, body string) error
}

// ConsoleMailFrom resolves the From address: the operator's explicit choice,
// else noreply on the ops account's platform name, else "" (no email).
func ConsoleMailFrom(explicit, platformSuffix string) string {
	if e := strings.ToLower(strings.TrimSpace(explicit)); e != "" {
		return e
	}
	if platformSuffix == "" {
		return ""
	}
	return "noreply@console." + release.OpsAccount + "." + platformSuffix
}

type sesConsoleMailer struct {
	from, domain string
	store        release.Store
	prov         mail.Provisioner
	dns          dnsrec.Writer
	dep          mail.Deployment
	ses          mail.EmailSender
	now          func() time.Time

	mu        sync.Mutex
	ensured   bool
	verified  bool
	configSet string
	checkedAt time.Time
}

func newSESConsoleMailer(from string, s release.Store, p mail.Provisioner, w dnsrec.Writer, d mail.Deployment, ses mail.EmailSender) *sesConsoleMailer {
	_, domain, _ := strings.Cut(from, "@")
	return &sesConsoleMailer{from: from, domain: domain, store: s, prov: p, dns: w, dep: d, ses: ses, now: time.Now}
}

// Ensure makes (or re-reads) the identity. Idempotent. Pending DNS records
// for a custom domain are logged for the operator to publish.
func (m *sesConsoleMailer) Ensure(ctx context.Context) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.ensureLocked(ctx)
}

func (m *sesConsoleMailer) ensureLocked(ctx context.Context) error {
	if m.ensured {
		return nil
	}
	_, pending, err := mail.EnsureDomain(ctx, m.store, m.prov, m.dns, m.dep,
		release.OpsAccount, consoleMailApp, m.domain, m.now())
	if err != nil {
		return fmt.Errorf("console mail: ensure %s: %w", m.domain, err)
	}
	for _, r := range pending {
		log.Printf("console mail: publish %s %s %s so %s can verify", r.Type, r.Name, strings.Join(r.Values, " "), m.domain)
	}
	m.ensured = true
	return nil
}

func (m *sesConsoleMailer) ready(ctx context.Context) (string, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.verified {
		return m.configSet, nil
	}
	if !m.checkedAt.IsZero() && m.now().Sub(m.checkedAt) < consoleMailPendingRecheck {
		return "", ErrConsoleMailNotReady
	}
	m.checkedAt = m.now()
	if err := m.ensureLocked(ctx); err != nil {
		return "", fmt.Errorf("%w: %v", ErrConsoleMailNotReady, err)
	}
	rec, err := mail.VerifyDomain(ctx, m.store, m.prov, m.dep, release.OpsAccount, m.domain, m.dep.Region, m.now())
	if err != nil {
		return "", fmt.Errorf("%w: %v", ErrConsoleMailNotReady, err)
	}
	st := rec.Regions[m.dep.Region]
	if st == nil || st.Status != mail.StatusVerified {
		return "", ErrConsoleMailNotReady
	}
	m.verified, m.configSet = true, st.ConfigSet
	return m.configSet, nil
}

func (m *sesConsoleMailer) Send(ctx context.Context, to, subject, body string) error {
	configSet, err := m.ready(ctx)
	if err != nil {
		return err
	}
	_, err = m.ses.SendEmail(ctx, mail.SimpleEmailInput(m.from, to, subject, body,
		mail.TenantName(release.OpsAccount), configSet))
	if err != nil {
		return fmt.Errorf("console mail: send to %s: %w", to, err)
	}
	return nil
}
```

- [ ] **Step 5: Reserve the referrer in `claimApp`** (`server.go`, after
  the `"new"` check):

```go
	// The console's own mail identity hangs off this name in the ops
	// account (consolemail.go). An app so named could be deleted, and an app
	// delete tears down every domain whose last referrer it was.
	if acct.Sqid == release.OpsAccount && name == consoleMailApp {
		return claimReserved, "", nil
	}
```

- [ ] **Step 6: Run the tests**

Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestConsoleMail' -count=1 && go test ./internal/mail -count=1`
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add internal/mail/sessend.go internal/console/consolemail.go internal/console/consolemail_test.go internal/console/server.go
git commit -m "console: its own email sender on the platform's mail layer"
```

---

### Task 4: `handleInvite` delivers

**Files:**
- Modify: `internal/console/server.go` (Config, Server, `handleInvite`)
- Modify: `internal/console/keymail_test.go` (`handleSend` enforces the
  one-recipient rule)
- Modify: `internal/console/server_account_test.go`
  (`TestInviteAcceptCreatesMembership` reads `fedBodies`)
- Test: `internal/console/invite_delivery_test.go`

**Interfaces:**
- Consumes: `Keymail.SendFederated`, `ErrNoKeymailMailbox` (Task 2);
  `ConsoleMailer` (Task 3).
- Produces: `Server.sendFederated func(ctx context.Context, to, subject, body string) (string, error)`
  (defaults to `s.km.SendFederated`); `Server.consoleMail ConsoleMailer`
  (nil unless wired); `Config.ConsoleMail ConsoleMailer`.

- [ ] **Step 1: Make the fake enforce Keymail's rule.** In `handleSend`,
  replace the "Any recipient is accepted" block with:

```go
	// The grant authorizes exactly one recipient: the user who approved it
	// (keymail appapi.go handleAppSend). This fake used to accept anyone,
	// which is how invites shipped broken.
	grantUser := strings.TrimPrefix(f.token, "user.tok-")
	if e.To != grantUser {
		jsonErr(w, http.StatusForbidden, "envelope to must be "+grantUser)
		return
	}
```

- [ ] **Step 2: Run the existing invite test and watch it fail.** This is
  the §1 regression:

Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestInviteAcceptCreatesMembership' -count=1`
Expected: FAIL, `invite link not delivered through keymail`.

Change its assertion to read federation deliveries:

```go
	// The link went into sam's Keymail inbox by federation.
	sent := fx.fk.fedBodies()
```

- [ ] **Step 3: Write the failing table test** in
  `invite_delivery_test.go`:

```go
package console

import (
	"context"
	"errors"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"testing"
	"time"
)

type fakeConsoleMail struct {
	mu   sync.Mutex
	err  error
	sent []string // to|body
}

func (f *fakeConsoleMail) Send(_ context.Context, to, _, body string) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.err != nil {
		return f.err
	}
	f.sent = append(f.sent, to+"|"+body)
	return nil
}

// flashAfter reads the flash cookie the last redirect set.
func flashAfter(t *testing.T, c *http.Client, base string) (kind, msg string) {
	t.Helper()
	u, _ := url.Parse(base)
	for _, ck := range c.Jar.Cookies(u) {
		if ck.Name == flashCookie {
			v, _ := url.QueryUnescape(ck.Value)
			kind, msg, _ = strings.Cut(v, "|")
			return kind, msg
		}
	}
	return "", ""
}

func TestInviteDelivery(t *testing.T) {
	cases := []struct {
		name      string
		to        string
		keymail   func(fx *consoleFixture)
		mail      *fakeConsoleMail // nil: no email fallback configured
		wantKind  string
		wantInMsg string // a fragment that identifies the outcome
		wantFed   bool
		wantMail  bool
	}{
		{name: "keymail delivers", to: "sam@keymail.dev", mail: &fakeConsoleMail{},
			wantKind: "ok", wantInMsg: "Keymail", wantFed: true},
		{name: "no mailbox, email delivers", to: "sam@gmail.com", mail: &fakeConsoleMail{},
			wantKind: "ok", wantInMsg: "emailed", wantMail: true},
		{name: "keymail refuses, email delivers", to: "sam@keymail.dev", mail: &fakeConsoleMail{},
			keymail:  func(fx *consoleFixture) { fx.fk.blocked["console@"+OriginHost(fx.ts.URL)] = true },
			wantKind: "ok", wantInMsg: "emailed", wantMail: true},
		{name: "both fail", to: "sam@gmail.com", mail: &fakeConsoleMail{err: ErrConsoleMailNotReady},
			wantKind: "err", wantInMsg: "/invite/"},
		{name: "no email configured", to: "sam@gmail.com",
			wantKind: "err", wantInMsg: "/invite/"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			fx := newConsoleFixture(t)
			if c.keymail != nil {
				c.keymail(fx)
			}
			if c.mail != nil {
				fx.srv.consoleMail = c.mail
			}
			owner := newBrowser(t)
			signUp(t, fx, owner, "code-owner")
			csrf := sessionToken(t, fx, owner)
			st, _, _ := postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {c.to}})
			if st != http.StatusSeeOther {
				t.Fatalf("invite = %d", st)
			}
			kind, msg := flashAfter(t, owner, fx.ts.URL)
			if kind != c.wantKind || !strings.Contains(msg, c.wantInMsg) {
				t.Fatalf("flash = %q %q, want %q containing %q", kind, msg, c.wantKind, c.wantInMsg)
			}
			pending, _ := fx.st.PendingInvites(OpsAccountID)
			if len(pending) != 1 {
				t.Fatalf("invite row missing: %+v", pending)
			}
			link := "/invite/" + pending[0].Token
			if got := strings.Contains(strings.Join(fx.fk.fedBodies(), "\n"), link); got != c.wantFed {
				t.Errorf("delivered by keymail = %v, want %v", got, c.wantFed)
			}
			mailed := c.mail != nil && strings.Contains(strings.Join(c.mail.sent, "\n"), c.to+"|") &&
				strings.Contains(strings.Join(c.mail.sent, "\n"), link)
			if mailed != c.wantMail {
				t.Errorf("emailed = %v, want %v", mailed, c.wantMail)
			}
		})
	}
}

// The §1 second fault: an owner with no stored grant used to get a false
// success. Delivery no longer reads the inviter's grant at all.
func TestInviteDeliversWithoutTheInvitersGrant(t *testing.T) {
	fx := newConsoleFixture(t)
	owner := newBrowser(t)
	signUp(t, fx, owner, "code-owner")
	if err := fx.st.SetGrantToken("paul@keymail.dev", "", time.Now()); err != nil {
		t.Fatal(err)
	}
	csrf := sessionToken(t, fx, owner)
	postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
	if len(fx.fk.fedBodies()) != 1 {
		t.Fatalf("fedBodies = %v, want one delivery", fx.fk.fedBodies())
	}
}

// Review Focus 3: a Keymail that never answers must not swallow the email leg.
func TestInviteFallsBackWhenKeymailHangs(t *testing.T) {
	fx := newConsoleFixture(t)
	mailer := &fakeConsoleMail{}
	fx.srv.consoleMail = mailer
	fx.srv.inviteTimeout = 300 * time.Millisecond
	fx.srv.inviteKeymailShare = 150 * time.Millisecond
	fx.srv.sendFederated = func(ctx context.Context, _, _, _ string) (string, error) {
		<-ctx.Done()
		return "", ctx.Err()
	}
	owner := newBrowser(t)
	signUp(t, fx, owner, "code-owner")
	csrf := sessionToken(t, fx, owner)
	start := time.Now()
	postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
	if d := time.Since(start); d > 2*time.Second {
		t.Fatalf("invite took %v", d)
	}
	if len(mailer.sent) != 1 {
		t.Fatalf("email leg not tried after a hung keymail: %v", mailer.sent)
	}
	if kind, _ := flashAfter(t, owner, fx.ts.URL); kind != "ok" {
		t.Fatalf("flash kind = %q, want ok", kind)
	}
}

var _ = errors.New
```

Drop the `errors` keep-alive if unused.

Review Focus 3 shapes the timeout. A hung Keymail must leave the email leg
some time, so Keymail gets its own share of the 20s budget:
`inviteKeymailShare` (default 12s) inside `inviteTimeout` (default 20s).
Both are Server fields so the test can shrink them.

- [ ] **Step 4: Run and watch it fail**

Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestInvite' -count=1`
Expected: FAIL to compile (`fx.srv.consoleMail`, `sendFederated`,
`inviteTimeout` undefined).

- [ ] **Step 5: Implement.** Add to `Config` (next to the mail fields):

```go
	// ConsoleMail is the console's own email sender (consolemail.go), the
	// invite fallback when Keymail cannot deliver. nil: no email fallback.
	ConsoleMail ConsoleMailer
```

Add to `Server`:

```go
	// Invite delivery (handleInvite): Keymail federation first, then the
	// console's own email. Fields so tests can substitute and shrink them.
	sendFederated      func(ctx context.Context, to, subject, body string) (string, error)
	consoleMail        ConsoleMailer
	inviteTimeout      time.Duration
	inviteKeymailShare time.Duration
```

In `NewServer`, after `s.sendApp = s.km.SendApp`:

```go
		s.sendFederated = s.km.SendFederated
```

and in the struct literal: `consoleMail: cfg.ConsoleMail, inviteTimeout:
20 * time.Second, inviteKeymailShare: 12 * time.Second,`.

Replace the doc comment and the delivery half of `handleInvite` (from
`link := ...` to the end):

```go
	link := s.cfg.Origin + "/invite/" + url.PathEscape(tok)
	subject, body := inviteMessage(addr, acct.Name, to, link)
	ctx, cancel := context.WithTimeout(r.Context(), s.inviteTimeout)
	defer cancel()
	kctx, kcancel := context.WithTimeout(ctx, s.inviteKeymailShare)
	_, err = s.sendFederated(kctx, to, subject, body)
	kcancel()
	if err == nil {
		back("ok", inviteFlashKeymail(to))
		return
	}
	log.Printf("console: invite to %s by keymail: %v", to, err)
	if s.consoleMail != nil {
		merr := s.consoleMail.Send(ctx, to, subject, body)
		if merr == nil {
			back("ok", inviteFlashEmail(to))
			return
		}
		log.Printf("console: invite to %s by email: %v", to, merr)
	}
	back("err", inviteFlashFailed(to, link))
}
```

The new doc comment on `handleInvite`:

```go
// handleInvite creates a single-use invitation and delivers its link:
// Keymail federation first (into the invitee's main inbox, as the console
// itself, no grant involved), then the console's own email, then the link
// shown to the owner to pass on. The invite row exists whichever way it
// went, so a failed delivery is a flash, not a rollback.
```

Add the copy helpers below `handleInvite`, using **Task 0's approved
text verbatim** in place of these drafts:

```go
func inviteMessage(inviter, account, to, link string) (subject, body string) {
	return "You've been invited to an account on the CARLOS platform",
		inviter + " invited you to the account \"" + account + "\" on the CARLOS platform.\n\n" +
			"Open this link while signed in as " + to + " to accept. It works once and expires in 7 days:\n\n" +
			link + "\n\nIf you weren't expecting this, ignore it — nothing happens without the link."
}

func inviteFlashKeymail(to string) string { return "Invited " + to + ". The link is in their Keymail inbox." }
func inviteFlashEmail(to string) string   { return "Invited " + to + ". We emailed them the link." }
func inviteFlashFailed(to, link string) string {
	return "Invited " + to + ", but delivering the link failed — you can send it on yourself: " + link
}
```

If the approved texts no longer contain "Keymail", "emailed" or the link,
update `wantInMsg` in the table to fragments of the approved text.

- [ ] **Step 6: Run the console package**

Run: `export GOCACHE=$TMPDIR/gocache; TMPDIR=/tmp/g go test ./internal/console -count=1`
(create `/tmp/g` first if missing: `mkdir -p /tmp/g`)
Expected: PASS. If another test sent an app message to someone other than
the grant user, it was relying on the old fake. Fix the test, not the fake.

- [ ] **Step 7: Commit**

```bash
git add internal/console/server.go internal/console/keymail_test.go internal/console/server_account_test.go internal/console/invite_delivery_test.go
git commit -m "console: invites deliver by keymail federation, then email, then by hand"
```

---

### Task 5: Wiring in `cmd/console`

**Files:**
- Modify: `cmd/console/main.go`
- Test: the existing `cmd/console` test file holding the `mailBackends`
  tests (`grep -ln mailBackends cmd/console/*_test.go`)

**Interfaces:**
- Consumes: `console.ConsoleMailer`; `mail.NewSESEmailSender`. The
  constructor must be exported for `cmd/console`, so in
  `internal/console/consolemail.go` add:

```go
// NewConsoleMailer builds the console's mailer, or nil when this deployment
// has no From address or no mail provisioner. The returned func runs the
// identity ensure; serve() starts it in the background.
func NewConsoleMailer(from string, s release.Store, p mail.Provisioner, w dnsrec.Writer, d mail.Deployment, ses mail.EmailSender) (ConsoleMailer, func(context.Context)) {
	if from == "" || p == nil || ses == nil || s == nil {
		return nil, func(context.Context) {}
	}
	m := newSESConsoleMailer(from, s, p, w, d, ses)
	return m, func(ctx context.Context) {
		if err := m.Ensure(ctx); err != nil {
			log.Printf("console mail: %v (retried on the next send)", err)
		}
	}
}
```

- Produces: `func consoleMailer(dep mail.Deployment, releases release.Store, p mail.Provisioner, w dnsrec.Writer, newSES func(context.Context, string) (mail.EmailSender, error)) (console.ConsoleMailer, func(context.Context))` in `cmd/console/main.go`.

- [ ] **Step 1: Write the failing test** next to the `mailBackends`
  tests:

```go
func TestConsoleMailerWiring(t *testing.T) {
	sesOK := func(context.Context, string) (mail.EmailSender, error) { return fakeSESClient{}, nil }
	dep := mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.test"}
	store := release.NewDirStore(t.TempDir())
	prov := mail.NewMemProvisioner("1")

	t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "")
	if m, _ := consoleMailer(dep, store, prov, nil, sesOK); m == nil {
		t.Error("suffix set, no explicit From: want a mailer on the default address")
	}
	if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK); m != nil {
		t.Error("no suffix, no explicit From: want no mailer")
	}
	if m, _ := consoleMailer(dep, store, nil, nil, sesOK); m != nil {
		t.Error("no provisioner: want no mailer")
	}
	t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "invites@tito.io")
	if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK); m == nil {
		t.Error("explicit From, no suffix: want a mailer")
	}
	sesFail := func(context.Context, string) (mail.EmailSender, error) { return nil, errors.New("no creds") }
	if m, _ := consoleMailer(dep, store, prov, nil, sesFail); m != nil {
		t.Error("SES client construction failed: want no mailer, not a crash")
	}
}

type fakeSESClient struct{}

func (fakeSESClient) SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) {
	return &sesv2.SendEmailOutput{}, nil
}
```

Add `errors`, `sesv2`, `release` and `mail` imports to the test file as
needed.

- [ ] **Step 2: Run and watch it fail**

Run: `export GOCACHE=$TMPDIR/gocache; go test ./cmd/console -run TestConsoleMailerWiring -count=1`
Expected: FAIL to compile, `undefined: consoleMailer`.

- [ ] **Step 3: Implement** in `cmd/console/main.go`, below `mailBackends`:

```go
// consoleMailer builds the console's own email sender, the invite fallback
// when Keymail cannot deliver (internal/console/consolemail.go). From is
// CARLOS_CONSOLE_MAIL_FROM, else noreply on the ops account's platform name.
// Like mailBackends, nothing here is fatal: no From, no provisioner or no
// SES client leaves invites on Keymail and the hand-off link.
func consoleMailer(dep mail.Deployment, releases release.Store, p mail.Provisioner, w dnsrec.Writer,
	newSES func(context.Context, string) (mail.EmailSender, error)) (console.ConsoleMailer, func(context.Context)) {
	from := console.ConsoleMailFrom(os.Getenv("CARLOS_CONSOLE_MAIL_FROM"), dep.PlatformSuffix)
	if from == "" || p == nil {
		log.Printf("console: no console email (set CARLOS_CONSOLE_MAIL_FROM or CARLOS_MAIL_PLATFORM_SUFFIX, and CARLOS_MAIL_REGION); invites go by Keymail or by hand")
		return nil, func(context.Context) {}
	}
	ses, err := newSES(context.Background(), dep.Region)
	if err != nil {
		log.Printf("console: no console email, SES client unavailable: %v", err)
		return nil, func(context.Context) {}
	}
	return console.NewConsoleMailer(from, releases, p, w, dep, ses)
}
```

In `serve()`, after `mailBackends` builds `mailProvisioner, dnsWriter` and
before `console.NewServer`:

```go
	consoleMail, ensureConsoleMail := consoleMailer(mailDep, releases, mailProvisioner, dnsWriter, mail.NewSESEmailSender)
```

(use whatever variable already holds the releases store passed as
`Releases:` in the Config literal), set `ConsoleMail: consoleMail,` in the
Config literal, and next to where `StartAlertWatcher` is started:

```go
	go ensureConsoleMail(ctx)
```

using the same context `StartAlertWatcher` gets.

- [ ] **Step 4: Run**

Run: `export GOCACHE=$TMPDIR/gocache; go test ./cmd/console ./internal/console -count=1 -run 'ConsoleMail|MailBackends'`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add cmd/console/main.go cmd/console/*_test.go internal/console/consolemail.go internal/console/consolemail_test.go
git commit -m "cmd/console: wire the console's email sender"
```

---

### Task 6: The SES send grant

**Files:**
- Modify: `infra/modules/region-host/variables.tf`
- Modify: `infra/modules/region-host/main.tf`
- Modify: `infra/modules/region-host/README.md` (the variables table, if
  it has one)

- [ ] **Step 1: Add the variables**

```hcl
variable "console_mail_from" {
  description = "The console's own From address (CARLOS_CONSOLE_MAIL_FROM, or noreply@console.bab.<platform suffix>). Empty: the host role gets no ses:SendEmail and the console sends no email of its own."
  type        = string
  default     = ""
}

variable "console_mail_region" {
  description = "SES region the console sends from (CARLOS_MAIL_REGION). Required when console_mail_from is set."
  type        = string
  default     = ""
}
```

- [ ] **Step 2: Add the policy** to `main.tf` after `host_store`:

```hcl
# The console's own email (invite fallback, console-invite-delivery spec §4).
# One identity, one From address. The configuration set and tenant are the
# ops account's ("carlos-bab"), which SES checks on a tenant-attributed send.
resource "aws_iam_role_policy" "host_console_mail" {
  count = var.console_mail_from == "" ? 0 : 1
  name  = "${var.iam_name}-console-mail"
  role  = aws_iam_role.host.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = ["ses:SendEmail"]
      Resource = [
        "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:identity/${split("@", var.console_mail_from)[1]}",
        "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:configuration-set/carlos-bab",
        "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:tenant/carlos-bab/*",
      ]
      Condition = {
        StringEquals = { "ses:FromAddress" = var.console_mail_from }
      }
    }]
  })
}
```

`mail.ConfigSetName("bab")` and `mail.TenantName("bab")` are both
`"carlos-bab"` (`internal/mail/walk.go:412`, `internal/mail/policy.go:84`).

- [ ] **Step 3: Validate**

Run: `cd infra/modules/region-host && tofu init -backend=false >/dev/null && tofu validate`
Expected: `Success! The configuration is valid.` If `tofu` is not on PATH,
say so in the task report rather than skipping silently.

- [ ] **Step 4: Commit**

```bash
git add infra/modules/region-host
git commit -m "region-host: optional ses:SendEmail grant for the console's own From"
```

---

### Task 7: Whole-branch gate

- [ ] **Step 1: Full tests and cross-builds**

```bash
export GOCACHE=$TMPDIR/gocache; mkdir -p /tmp/g
TMPDIR=/tmp/g go test ./... -count=1 2>&1 | tail -30
GOOS=darwin go build ./... && GOOS=windows go build ./... && go vet ./internal/console ./internal/mail ./cmd/console
```

Expected: all PASS, builds clean.

- [ ] **Step 2: Document the variable** wherever `CARLOS_MAIL_REGION` is
  documented for operators (`grep -rn CARLOS_MAIL_REGION docs README.md`).
  If nowhere, add one paragraph to the console section of README.md naming
  `CARLOS_CONSOLE_MAIL_FROM`, its default and the `region-host` variables.
  Paul's copy review covers README prose, so run the copy-review skill on
  that paragraph before committing it.

- [ ] **Step 3: Push and describe**

```bash
git push origin design-console-invite-delivery
amadan branch describe -summary "Console invites deliver: Keymail federation as console@<origin>, then the console's own SES email via the platform mail layer (ops account bab), then the hand-off link. Fixes invites never delivering since 35602776." carlos/platform design-console-invite-delivery
```

Merging is Paul's call (`amadan branch merge carlos/platform
design-console-invite-delivery -squash -expect <full sha>`), after the
final review.
