carlos / platform Public

plan: console invite delivery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev f4ac8bd9cd6da3fb684b3c30b25fbf08f531133c parent 7cf6526
1 file changed, +1838 −0
  • docs/superpowers/plans/2026-10-02-console-invite-delivery.md +1838 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-02-console-invite-delivery.md b/docs/superpowers/plans/2026-10-02-console-invite-delivery.md
new file mode 100644
index 000000000..80deb3c24
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-02-console-invite-delivery.md
@@ -0,0 +1,1838 @@
+# 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.