plan: console invite delivery
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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. |