carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Console Invite Delivery Implementation Plan
2
3> **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.
4
5**Goal:** Account invites reach the invitee: by Keymail federation when
6they have a Keymail mailbox, by email through the platform's own mail
7layer when not, and as a hand-off link only when both fail.
8
9**Architecture:** `Keymail.SendFederated` speaks Keymail's federation
10protocol (lookup, challenge, proof of work, register, deliver) as
11`console@<origin host>`, using the console's existing signing key.
12`ConsoleMailer` owns one SES identity created through `mail.EnsureDomain`
13under the ops account `bab`, and sends with SESv2 `SendEmail` on the
14console's own AWS role. `handleInvite` tries them in that order.
15
16**Tech Stack:** Go 1.25 stdlib (crypto/ecdh, crypto/ecdsa, crypto/sha256,
17net/http), aws-sdk-go-v2 `service/sesv2` v1.66.6 (already in go.mod),
18OpenTofu for `infra/modules/region-host`.
19
20**Spec:** `docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md`
21
22## Global Constraints
23
24- Work in the worktree `.claude/worktrees/console-invite-delivery` on branch
25 `design-console-invite-delivery`; never `git checkout -- <file>` and never
26 bare `git stash`.
27- Go builds from this sandbox need `export GOCACHE=$TMPDIR/gocache` in the
28 same command; socket tests need `TMPDIR=/tmp/g` (see AGENTS.md).
29- Every user-facing string (flashes, message subject and body) is approved
30 by Paul through the copy-review skill before it is written into a file.
31 Approved text replaces the drafts in this plan verbatim. No em dashes in
32 new copy unless Paul's approved text has them.
33- Keymail protocol constants are copied from the Keymail source with a
34 comment naming the file: `attestContext = "keymail-attest-v1"`
35 (`keymail/crypto.go:35`), the pow hash `SHA-256(nonce || sign_pub_raw ||
36 counter_be64)` with `nonce = SHA-256(challenge_token)`
37 (`keymail/internal/pow`, `keymail/federation.go` handleRegister).
38- `maxFederationPowBits = 24`.
39- Federation From address: `console@` + `OriginHost(origin)`, the same From
40 `SendApp` uses.
41- The console's mail identity lives under account `release.OpsAccount`
42 (`"bab"`) with referrer app name `console-mail`, which `claimApp` refuses
43 in `bab`.
44- From resolution: `CARLOS_CONSOLE_MAIL_FROM` if set; else
45 `noreply@console.bab.<CARLOS_MAIL_PLATFORM_SUFFIX>` if the suffix is set;
46 else no email fallback. No email fallback either when
47 `cfg.MailProvisioner` is nil.
48- `handleInvite` uses one 20s context for the whole delivery attempt.
49- darwin and windows cross-builds stay green (`GOOS=darwin go build ./...`,
50 `GOOS=windows go build ./...`).
51
52## Review Focus
53
541. **The console's signing key changed since it registered with a
55 mailbox.** Keymail answers 403 "signing key does not match registered
56 key" rather than `unknown_sender`. A person expects the invite to go
57 through anyway. `SendFederated` treats that 403 like `unknown_sender`:
58 register (full price) and retry once. Test in Task 2.
592. **An invitee's mailbox exists but is unclaimed** (409 "inbox not yet
60 claimed" from lookup). A person expects the email fallback. The lookup
61 failure maps to `ErrNoKeymailMailbox` whatever the status. Test in Task 2.
623. **Keymail is down or slow.** A person expects the page to come back
63 inside the timeout with the email or hand-off result, not hang. The
64 shared 20s context bounds both legs. Task 4 tests a Keymail that never
65 answers within a short test timeout, with email still tried.
664. **The identity is not verified yet** (fresh deployment, DNS still
67 converging). A person expects the hand-off link, not a 500. `Send`
68 returns `ErrConsoleMailNotReady`, `handleInvite` falls through. Tests in
69 Tasks 3 and 4.
705. **Someone creates an app named `console-mail` in `bab` and deletes
71 it.** A person expects the console's mail identity to survive. Creation
72 is refused at `claimApp`. Test in Task 3.
73
74---
75
76## File Structure
77
78- Create `internal/console/keymailpow.go`: pow mine and verify, ported from
79 Keymail. One responsibility, easy to test alone.
80- Create `internal/console/keymailfed.go`: `SendFederated`, sender
81 registration, `attestationMsg`, the typed Keymail error.
82- Modify `internal/console/keymail.go`: `do()` returns `*keymailError`;
83 `SendApp`'s seal-and-sign moves into `sealEnvelope`, shared with
84 federation.
85- Create `internal/mail/sessend.go`: the narrow SESv2 send seam and its
86 constructor. It sits next to the provisioner's SES code.
87- Create `internal/console/consolemail.go`: `ConsoleMailer`, From
88 resolution, the SES-backed mailer with ensure/ready/send.
89- Modify `internal/console/server.go`: Config field, Server fields,
90 `claimApp` reservation, `handleInvite`.
91- Modify `cmd/console/main.go`: read `CARLOS_CONSOLE_MAIL_FROM`, build the
92 SES send client, start the ensure goroutine.
93- Modify `infra/modules/region-host/{variables.tf,main.tf,README.md}`: the
94 SES send grant.
95- Tests: `keymailpow_test.go`, `keymailfed_test.go`, `consolemail_test.go`,
96 `invite_delivery_test.go` (new), `keymail_test.go` (the fake),
97 `server_account_test.go` (existing invite test), `cmd/console`
98 (env parsing).
99
100---
101
102### Task 0: Copy review
103
104**Files:** none until approved; strings land in Task 4.
105
106- [ ] **Step 1: Run the copy-review skill** over these strings (context:
107 the Team page flash after an owner invites someone, and the invite
108 message itself, now also sent as plain email):
109
110 1. `invite.flash.keymail`: "Invited {to}. The link is in their Keymail inbox."
111 2. `invite.flash.email`: "Invited {to}. We emailed them the link."
112 3. `invite.flash.failed`: "Invited {to}, but delivering the link failed — you can send it on yourself: {link}"
113 4. `invite.subject`: "You've been invited to an account on the CARLOS platform"
114 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."
115
116- [ ] **Step 2: Record the approved text** in
117 `copy-review/strings.json` in the worktree and commit it:
118
119```bash
120git add copy-review/strings.json
121git commit -m "copy: invite delivery strings, as approved"
122```
123
124Task 4 uses the approved text verbatim. Do not start Task 4 without it.
125
126---
127
128### Task 1: Proof of work
129
130**Files:**
131- Create: `internal/console/keymailpow.go`
132- Test: `internal/console/keymailpow_test.go`
133
134**Interfaces:**
135- Produces: `const maxFederationPowBits = 24`;
136 `func powVerify(nonce, signPub []byte, counter uint64, bits int) bool`;
137 `func powMine(ctx context.Context, nonce, signPub []byte, bits int) (uint64, error)`.
138
139- [ ] **Step 1: Write the failing test**
140
141```go
142package console
143
144import (
145 "context"
146 "crypto/sha256"
147 "encoding/binary"
148 "errors"
149 "math/bits"
150 "testing"
151 "time"
152)
153
154// refLeadingZeros is written out independently of keymailpow.go so the
155// test does not check the code against itself.
156func refLeadingZeros(h []byte) int {
157 n := 0
158 for _, b := range h {
159 if b == 0 {
160 n += 8
161 continue
162 }
163 return n + bits.LeadingZeros8(b)
164 }
165 return n
166}
167
168func refHash(nonce, signPub []byte, counter uint64) []byte {
169 h := sha256.New()
170 h.Write(nonce)
171 h.Write(signPub)
172 var c [8]byte
173 binary.BigEndian.PutUint64(c[:], counter)
174 h.Write(c[:])
175 return h.Sum(nil)
176}
177
178func TestPowMineFindsAVerifiableCounter(t *testing.T) {
179 nonce := sha256.Sum256([]byte("challenge-token"))
180 signPub := []byte("sign-pub-bytes")
181 ctr, err := powMine(context.Background(), nonce[:], signPub, 12)
182 if err != nil {
183 t.Fatal(err)
184 }
185 if got := refLeadingZeros(refHash(nonce[:], signPub, ctr)); got < 12 {
186 t.Fatalf("counter %d gives %d leading zero bits, want >= 12", ctr, got)
187 }
188 if !powVerify(nonce[:], signPub, ctr, 12) {
189 t.Fatal("powVerify rejects the counter powMine found")
190 }
191 if powVerify(nonce[:], []byte("another key"), ctr, 12) && refLeadingZeros(refHash(nonce[:], []byte("another key"), ctr)) < 12 {
192 t.Fatal("powVerify accepted a solution for a different key")
193 }
194}
195
196func TestPowZeroBitsIsFree(t *testing.T) {
197 if !powVerify(nil, nil, 0, 0) {
198 t.Fatal("0 bits must verify")
199 }
200 ctr, err := powMine(context.Background(), nil, nil, 0)
201 if err != nil || ctr != 0 {
202 t.Fatalf("powMine(0 bits) = %d, %v; want 0, nil", ctr, err)
203 }
204}
205
206func TestPowMineHonoursCancellation(t *testing.T) {
207 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
208 defer cancel()
209 // 64 bits will never be found.
210 _, err := powMine(ctx, []byte("n"), []byte("k"), 64)
211 if !errors.Is(err, context.DeadlineExceeded) {
212 t.Fatalf("powMine under a 20ms deadline = %v, want DeadlineExceeded", err)
213 }
214}
215```
216
217- [ ] **Step 2: Run it and watch it fail**
218
219Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestPow' -count=1`
220Expected: FAIL to compile, `undefined: powMine`.
221
222- [ ] **Step 3: Implement**
223
224```go
225package console
226
227// Proof of work for Keymail federation, ported from keymail
228// internal/pow/pow.go and the nonce rule in federation.go handleRegister.
229// An unknown sender pays it once per recipient mailbox to register its
230// signing key; after that, deliveries from the same key are free.
231//
232// nonce = SHA-256(challenge token)
233// find counter: SHA-256(nonce || sign_pub_raw || counter_be64) has >= bits leading zero bits
234
235import (
236 "context"
237 "crypto/sha256"
238 "encoding/binary"
239 "math/bits"
240)
241
242// maxFederationPowBits is the highest price the console will pay. A mailbox
243// sets its own price, and an invite runs inside an HTTP request: 24 bits is
244// about sixteen million hashes, a few seconds on one core. Keymail's default
245// is 20.
246const maxFederationPowBits = 24
247
248func powLeadingZeroBits(h []byte) int {
249 n := 0
250 for _, b := range h {
251 if b == 0 {
252 n += 8
253 continue
254 }
255 return n + bits.LeadingZeros8(b)
256 }
257 return n
258}
259
260func powVerify(nonce, signPub []byte, counter uint64, difficulty int) bool {
261 if difficulty <= 0 {
262 return true
263 }
264 h := sha256.New()
265 h.Write(nonce)
266 h.Write(signPub)
267 var c [8]byte
268 binary.BigEndian.PutUint64(c[:], counter)
269 h.Write(c[:])
270 return powLeadingZeroBits(h.Sum(nil)) >= difficulty
271}
272
273// powMine searches counters from 0 on one goroutine, checking ctx every
274// 65536 attempts. One core is enough at the prices the console accepts.
275func powMine(ctx context.Context, nonce, signPub []byte, difficulty int) (uint64, error) {
276 if difficulty <= 0 {
277 return 0, nil
278 }
279 prefix := make([]byte, 0, len(nonce)+len(signPub)+8)
280 prefix = append(append(prefix, nonce...), signPub...)
281 buf := append(prefix, make([]byte, 8)...)
282 n := len(prefix)
283 for counter := uint64(0); ; counter++ {
284 if counter&0xffff == 0 {
285 if err := ctx.Err(); err != nil {
286 return 0, err
287 }
288 }
289 binary.BigEndian.PutUint64(buf[n:], counter)
290 sum := sha256.Sum256(buf)
291 if powLeadingZeroBits(sum[:]) >= difficulty {
292 return counter, nil
293 }
294 }
295}
296```
297
298- [ ] **Step 4: Run the tests**
299
300Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestPow' -count=1`
301Expected: PASS.
302
303- [ ] **Step 5: Commit**
304
305```bash
306git add internal/console/keymailpow.go internal/console/keymailpow_test.go
307git commit -m "console: keymail federation proof of work"
308```
309
310---
311
312### Task 2: `Keymail.SendFederated` and the federation fake
313
314**Files:**
315- Create: `internal/console/keymailfed.go`
316- Modify: `internal/console/keymail.go` (`do`, `SendApp`)
317- Modify: `internal/console/keymail_test.go` (the fake)
318- Test: `internal/console/keymailfed_test.go`
319
320**Interfaces:**
321- Consumes: `powMine`, `maxFederationPowBits` (Task 1); existing
322 `sealToBox`, `signRaw`, `envelopeMsg`, `OriginHost`, `envelope`.
323- Produces:
324 - `var ErrNoKeymailMailbox error`
325 - `var ErrFederationPriceTooHigh error`
326 - `func (k *Keymail) SendFederated(ctx context.Context, to, subject, body string) (msgID string, err error)`
327 - `type keymailError struct { Status int; Method, Path, Msg string }`
328 with `Error() string`. `do()` returns it for every non-200 answer.
329 - `func (k *Keymail) sealEnvelope(from, to, boxPub, subject, body string) (envelope, error)`
330 - Fake additions: `fakeKeymail.fedSends []sentMessage` (Kind
331 `"federation"`), `fakeKeymail.powBits int` (default 8),
332 `fakeKeymail.noMailbox map[string]bool`, `fakeKeymail.blocked
333 map[string]bool`, `fakeKeymail.senders map[string]string` (from →
334 sign_pub), `fakeKeymail.registrations int`,
335 `func (f *fakeKeymail) fedBodies() []string`.
336
337- [ ] **Step 1: Make `do()` return a typed error.** In `keymail.go`,
338 replace the non-200 branch so the message text stays byte-identical:
339
340```go
341// keymailError is a non-200 answer from Keymail. Error() keeps the text
342// do() has always produced; Status and Msg let callers classify without
343// matching strings.
344type keymailError struct {
345 Status int
346 Method, Path string
347 Msg string // Keymail's {"error": ...}, or the HTTP status line
348}
349
350func (e *keymailError) Error() string {
351 return fmt.Sprintf("keymail: %s %s: %s", e.Method, e.Path, e.Msg)
352}
353```
354
355and in `do()`:
356
357```go
358 if res.StatusCode != http.StatusOK {
359 var e struct {
360 Error string `json:"error"`
361 }
362 msg := res.Status
363 if json.Unmarshal(raw, &e) == nil && e.Error != "" {
364 msg = e.Error
365 }
366 return res.StatusCode, &keymailError{Status: res.StatusCode, Method: method, Path: path, Msg: msg}
367 }
368```
369
370- [ ] **Step 2: Extract `sealEnvelope`** from `SendApp` (same file), and
371 make `SendApp` call it. Behaviour is unchanged:
372
373```go
374// sealEnvelope seals {subject, body} to the recipient's box key and signs
375// the envelope with the console's key. SendApp and SendFederated share it,
376// so the two wires cannot drift apart.
377func (k *Keymail) sealEnvelope(from, to, boxPub, subject, body string) (envelope, error) {
378 payload, err := json.Marshal(map[string]string{"subject": subject, "body": body})
379 if err != nil {
380 return envelope{}, err
381 }
382 blob, err := sealToBox(boxPub, payload)
383 if err != nil {
384 return envelope{}, fmt.Errorf("keymail: seal to %s: %w", to, err)
385 }
386 idb := make([]byte, 16)
387 if _, err := rand.Read(idb); err != nil {
388 return envelope{}, err
389 }
390 id := hex.EncodeToString(idb)
391 date := time.Now().UTC().Format(time.RFC3339)
392 sig, err := signRaw(k.signKey, envelopeMsg(id, from, to, date, blob))
393 if err != nil {
394 return envelope{}, err
395 }
396 return envelope{
397 V: protocolVersion, ID: id, From: from, To: to, Date: date,
398 SignPub: k.SignPub(), Blob: blob, Sig: sig,
399 }, nil
400}
401```
402
403`SendApp` body after the lookup becomes:
404
405```go
406 env, err := k.sealEnvelope(from, to, lr.BoxPub, subject, body)
407 if err != nil {
408 return "", err
409 }
410 status, err := k.do(ctx, http.MethodPost, "/api/app/send", token,
411 map[string]any{"envelope": env, "kind": "app"}, nil)
412 if status == http.StatusUnauthorized {
413 return "", ErrGrantRevoked
414 }
415 if err != nil {
416 return "", err
417 }
418 return env.ID, nil
419```
420
421Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -count=1 -run 'Keymail|SendApp|Signin|Callback|StepUp'`
422Expected: PASS (pure refactor).
423
424- [ ] **Step 3: Extend the fake** in `keymail_test.go`. Add the fields
425 listed under Interfaces, initialise the maps and `powBits: 8` in
426 `newFakeKeymail`, and register the routes:
427
428```go
429 mux.HandleFunc("POST /api/federation/challenge", f.handleChallenge)
430 mux.HandleFunc("POST /api/federation/register", f.handleRegister)
431 mux.HandleFunc("POST /api/federation/deliver", f.handleDeliver)
432```
433
434Replace `handleLookup` so it routes like the relay (`keymail/relay.go`
435`proxyAPI`) and prices like `federation.go` `handleLookup`:
436
437```go
438func (f *fakeKeymail) handleLookup(w http.ResponseWriter, r *http.Request) {
439 f.mu.Lock()
440 defer f.mu.Unlock()
441 addr := r.URL.Query().Get("addr")
442 local, host, ok := strings.Cut(addr, "@")
443 if !ok || host != "keymail.dev" {
444 jsonErr(w, http.StatusBadRequest, "could not determine target mailbox")
445 return
446 }
447 if f.noMailbox[local] {
448 jsonErr(w, http.StatusNotFound, "no such mailbox")
449 return
450 }
451 bits := f.powBits
452 if _, known := f.senders[r.URL.Query().Get("from")]; known {
453 bits = 0 // keymail prices a known sender at 0 whatever key it brings
454 }
455 w.Header().Set("Content-Type", "application/json")
456 json.NewEncoder(w).Encode(map[string]any{
457 "addr": addr,
458 "sign_pub": "",
459 "box_pub": base64.StdEncoding.EncodeToString(f.boxPriv.PublicKey().Bytes()),
460 "pow_bits": bits,
461 })
462}
463```
464
465Add the three verbs. Challenges are tracked in a map keyed by token. The
466real Keymail seals them instead, but the wire is the same:
467
468```go
469type fakeChallenge struct {
470 to, from, signPub string
471 bits int
472}
473
474func (f *fakeKeymail) handleChallenge(w http.ResponseWriter, r *http.Request) {
475 f.mu.Lock()
476 defer f.mu.Unlock()
477 var req struct{ To, From, SignPub string }
478 var raw struct {
479 To string `json:"to"`
480 From string `json:"from"`
481 SignPub string `json:"sign_pub"`
482 }
483 if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
484 jsonErr(w, http.StatusBadRequest, "bad json")
485 return
486 }
487 req.To, req.From, req.SignPub = raw.To, raw.From, raw.SignPub
488 if f.blocked[req.From] {
489 jsonErr(w, http.StatusForbidden, "blocked")
490 return
491 }
492 bits := f.powBits
493 if f.senders[req.From] == req.SignPub {
494 bits = 0
495 }
496 tok := make([]byte, 16)
497 rand.Read(tok)
498 token := base64.RawURLEncoding.EncodeToString(tok)
499 if f.challenges == nil {
500 f.challenges = map[string]fakeChallenge{}
501 }
502 f.challenges[token] = fakeChallenge{to: req.To, from: req.From, signPub: req.SignPub, bits: bits}
503 w.Header().Set("Content-Type", "application/json")
504 json.NewEncoder(w).Encode(map[string]any{"challenge": token, "bits": bits, "expires_at": time.Now().Add(time.Minute).Unix()})
505}
506
507func (f *fakeKeymail) handleRegister(w http.ResponseWriter, r *http.Request) {
508 f.mu.Lock()
509 defer f.mu.Unlock()
510 var req struct {
511 To string `json:"to"`
512 Challenge string `json:"challenge"`
513 Counter uint64 `json:"counter"`
514 From string `json:"from"`
515 SignPub string `json:"sign_pub"`
516 BoxPub string `json:"box_pub"`
517 Attestation string `json:"attestation"`
518 }
519 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
520 jsonErr(w, http.StatusBadRequest, "bad json")
521 return
522 }
523 c, ok := f.challenges[req.Challenge]
524 if !ok {
525 jsonErr(w, http.StatusBadRequest, "bad challenge")
526 return
527 }
528 delete(f.challenges, req.Challenge)
529 if c.from != req.From || c.signPub != req.SignPub {
530 jsonErr(w, http.StatusBadRequest, "challenge does not match this sender")
531 return
532 }
533 nonce := sha256.Sum256([]byte(req.Challenge))
534 signPubRaw, _ := base64.StdEncoding.DecodeString(req.SignPub)
535 if refLeadingZeros(refHash(nonce[:], signPubRaw, req.Counter)) < c.bits {
536 jsonErr(w, http.StatusForbidden, "proof of work does not meet difficulty")
537 return
538 }
539 attest := []byte("keymail-attest-v1\n" + req.From + "\n" + req.SignPub + "\n" + req.BoxPub)
540 if !testVerify(req.SignPub, attest, req.Attestation) {
541 jsonErr(w, http.StatusForbidden, "bad key attestation")
542 return
543 }
544 f.senders[req.From] = req.SignPub
545 f.registrations++
546 w.Header().Set("Content-Type", "application/json")
547 json.NewEncoder(w).Encode(map[string]string{"status": "proven"})
548}
549
550func (f *fakeKeymail) handleDeliver(w http.ResponseWriter, r *http.Request) {
551 f.mu.Lock()
552 defer f.mu.Unlock()
553 var req struct {
554 Envelope envelope `json:"envelope"`
555 }
556 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
557 jsonErr(w, http.StatusBadRequest, "bad json")
558 return
559 }
560 e := req.Envelope
561 if f.blocked[e.From] {
562 jsonErr(w, http.StatusForbidden, "blocked")
563 return
564 }
565 reg, known := f.senders[e.From]
566 if !known {
567 w.Header().Set("Content-Type", "application/json")
568 w.WriteHeader(http.StatusForbidden)
569 json.NewEncoder(w).Encode(map[string]any{"error": "unknown_sender", "pow_bits": f.powBits})
570 return
571 }
572 if reg != e.SignPub {
573 jsonErr(w, http.StatusForbidden, "signing key does not match registered key")
574 return
575 }
576 if e.V != "keymail1" || e.ID == "" || e.Date == "" ||
577 !testVerify(e.SignPub, envelopeMsg(e.ID, e.From, e.To, e.Date, e.Blob), e.Sig) {
578 jsonErr(w, http.StatusBadRequest, "bad envelope signature")
579 return
580 }
581 plain, err := testOpen(f.boxPriv, e.Blob)
582 if err != nil {
583 jsonErr(w, http.StatusBadRequest, "blob does not open")
584 return
585 }
586 var p struct{ Subject, Body string }
587 json.Unmarshal(plain, &p)
588 f.fedSends = append(f.fedSends, sentMessage{ID: e.ID, From: e.From, To: e.To, Subject: p.Subject, Body: p.Body, Kind: "federation"})
589 w.Header().Set("Content-Type", "application/json")
590 json.NewEncoder(w).Encode(map[string]string{"status": "delivered"})
591}
592
593func (f *fakeKeymail) fedBodies() []string {
594 f.mu.Lock()
595 defer f.mu.Unlock()
596 out := make([]string, 0, len(f.fedSends))
597 for _, s := range f.fedSends {
598 out = append(out, s.Body)
599 }
600 return out
601}
602```
603
604Delete the throwaway `req struct{ To, From, SignPub string }` in
605`handleChallenge` if the linter flags it; decoding into `raw` is enough.
606`refLeadingZeros` and `refHash` come from `keymailpow_test.go` (same
607package). Add a `challenges map[string]fakeChallenge` field.
608
609- [ ] **Step 4: Write the failing tests** in `keymailfed_test.go`:
610
611```go
612package console
613
614import (
615 "context"
616 "crypto/ecdsa"
617 "crypto/elliptic"
618 "crypto/rand"
619 "errors"
620 "strings"
621 "testing"
622)
623
624func fedClient(t *testing.T, fk *fakeKeymail) *Keymail {
625 t.Helper()
626 k := NewKeymail(fk.srv.URL, "https://console.example.test")
627 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
628 if err != nil {
629 t.Fatal(err)
630 }
631 k.SetSigningKey(key)
632 return k
633}
634
635func TestSendFederatedUnknownSenderRegistersThenDelivers(t *testing.T) {
636 fk := newFakeKeymail(t, "paul@keymail.dev")
637 k := fedClient(t, fk)
638 id, err := k.SendFederated(context.Background(), "bill@keymail.dev", "subj", "the link")
639 if err != nil {
640 t.Fatal(err)
641 }
642 if fk.registrations != 1 {
643 t.Fatalf("registrations = %d, want 1", fk.registrations)
644 }
645 if len(fk.fedSends) != 1 || fk.fedSends[0].ID != id || fk.fedSends[0].Body != "the link" ||
646 fk.fedSends[0].From != "console@console.example.test" || fk.fedSends[0].To != "bill@keymail.dev" {
647 t.Fatalf("fedSends = %+v", fk.fedSends)
648 }
649}
650
651func TestSendFederatedKnownSenderPaysNothing(t *testing.T) {
652 fk := newFakeKeymail(t, "paul@keymail.dev")
653 k := fedClient(t, fk)
654 ctx := context.Background()
655 if _, err := k.SendFederated(ctx, "bill@keymail.dev", "s", "one"); err != nil {
656 t.Fatal(err)
657 }
658 if _, err := k.SendFederated(ctx, "bill@keymail.dev", "s", "two"); err != nil {
659 t.Fatal(err)
660 }
661 if fk.registrations != 1 {
662 t.Fatalf("registrations = %d after two sends, want 1", fk.registrations)
663 }
664}
665
666// Review Focus 1: the console's key changed since it registered.
667func TestSendFederatedReRegistersARotatedKey(t *testing.T) {
668 fk := newFakeKeymail(t, "paul@keymail.dev")
669 fk.senders["console@console.example.test"] = "an-old-key"
670 k := fedClient(t, fk)
671 if _, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b"); err != nil {
672 t.Fatal(err)
673 }
674 if fk.registrations != 1 || len(fk.fedSends) != 1 {
675 t.Fatalf("registrations=%d fedSends=%d, want 1 and 1", fk.registrations, len(fk.fedSends))
676 }
677}
678
679func TestSendFederatedRefusesAPriceAboveTheCap(t *testing.T) {
680 fk := newFakeKeymail(t, "paul@keymail.dev")
681 fk.powBits = maxFederationPowBits + 1
682 k := fedClient(t, fk)
683 _, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b")
684 if !errors.Is(err, ErrFederationPriceTooHigh) {
685 t.Fatalf("err = %v, want ErrFederationPriceTooHigh", err)
686 }
687 if fk.registrations != 0 {
688 t.Fatal("registered despite refusing the price")
689 }
690}
691
692// Review Focus 2 and the non-Keymail host.
693func TestSendFederatedNoMailbox(t *testing.T) {
694 fk := newFakeKeymail(t, "paul@keymail.dev")
695 fk.noMailbox["ghost"] = true
696 k := fedClient(t, fk)
697 for _, to := range []string{"ghost@keymail.dev", "bill@gmail.com"} {
698 _, err := k.SendFederated(context.Background(), to, "s", "b")
699 if !errors.Is(err, ErrNoKeymailMailbox) {
700 t.Errorf("SendFederated(%s) = %v, want ErrNoKeymailMailbox", to, err)
701 }
702 }
703}
704
705func TestSendFederatedBlocked(t *testing.T) {
706 fk := newFakeKeymail(t, "paul@keymail.dev")
707 fk.blocked["console@console.example.test"] = true
708 k := fedClient(t, fk)
709 _, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b")
710 if err == nil || !strings.Contains(err.Error(), "blocked") {
711 t.Fatalf("err = %v, want a blocked error", err)
712 }
713}
714
715func TestSendFederatedWithoutKey(t *testing.T) {
716 fk := newFakeKeymail(t, "paul@keymail.dev")
717 k := NewKeymail(fk.srv.URL, "https://console.example.test")
718 if _, err := k.SendFederated(context.Background(), "bill@keymail.dev", "s", "b"); err == nil {
719 t.Fatal("SendFederated without a signing key succeeded")
720 }
721}
722```
723
724- [ ] **Step 5: Run and watch them fail**
725
726Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestSendFederated' -count=1`
727Expected: FAIL to compile, `k.SendFederated undefined`.
728
729- [ ] **Step 6: Implement `keymailfed.go`**
730
731```go
732package console
733
734// Delivery to someone who never granted the console anything: Keymail's
735// federation protocol (keymail federation.go), the same one a Keymail
736// instance uses to write to another. An unknown sender pays a proof of work
737// to register its signing key with the recipient's mailbox, then delivers
738// signed, sealed envelopes into the main inbox.
739//
740// The app API (SendApp) cannot do this: a grant delivers only to the user
741// who approved it (keymail appapi.go handleAppSend), which is why invites
742// sent through it never arrived.
743
744import (
745 "context"
746 "crypto/ecdh"
747 "crypto/rand"
748 "crypto/sha256"
749 "encoding/base64"
750 "errors"
751 "fmt"
752 "net/http"
753 "net/url"
754 "sync"
755)
756
757var (
758 // ErrNoKeymailMailbox: Keymail has no mailbox for the address (not a
759 // Keymail host, unknown local part, or a mailbox not yet claimed). The
760 // caller should try another channel.
761 ErrNoKeymailMailbox = errors.New("keymail: no mailbox for that address")
762 // ErrFederationPriceTooHigh: the mailbox asks more proof of work than
763 // maxFederationPowBits.
764 ErrFederationPriceTooHigh = errors.New("keymail: the recipient's proof-of-work price is above the console's cap")
765)
766
767// attestContext is keymail crypto.go's attestation domain separator.
768const attestContext = "keymail-attest-v1"
769
770func attestationMsg(addr, signPub, boxPub string) []byte {
771 return []byte(attestContext + "\n" + addr + "\n" + signPub + "\n" + boxPub)
772}
773
774// replyBox is the box key registration records for replies. Nobody replies
775// to an invite and the console serves no Keymail endpoints, so the key is
776// per process and its private half is dropped.
777var (
778 replyBoxOnce sync.Once
779 replyBoxPub string
780 replyBoxErr error
781)
782
783func federationReplyBox() (string, error) {
784 replyBoxOnce.Do(func() {
785 k, err := ecdh.P256().GenerateKey(rand.Reader)
786 if err != nil {
787 replyBoxErr = err
788 return
789 }
790 replyBoxPub = base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
791 })
792 return replyBoxPub, replyBoxErr
793}
794
795// SendFederated delivers one sealed message to any Keymail mailbox as
796// console@<origin host>. It registers the console's key with the mailbox
797// first when Keymail asks for it.
798func (k *Keymail) SendFederated(ctx context.Context, to, subject, body string) (string, error) {
799 if k.signKey == nil {
800 return "", errors.New("keymail: SendFederated requires a signing key")
801 }
802 from := "console@" + OriginHost(k.origin)
803 var lr struct {
804 BoxPub string `json:"box_pub"`
805 PowBits int `json:"pow_bits"`
806 }
807 lu := "/api/federation/lookup?addr=" + url.QueryEscape(to) + "&from=" + url.QueryEscape(from)
808 if _, err := k.do(ctx, http.MethodGet, lu, "", nil, &lr); err != nil {
809 return "", fmt.Errorf("%w: %v", ErrNoKeymailMailbox, err)
810 }
811 if lr.PowBits > maxFederationPowBits {
812 return "", ErrFederationPriceTooHigh
813 }
814 if lr.PowBits > 0 {
815 if err := k.registerSender(ctx, to, from); err != nil {
816 return "", err
817 }
818 }
819 env, err := k.sealEnvelope(from, to, lr.BoxPub, subject, body)
820 if err != nil {
821 return "", err
822 }
823 err = k.deliver(ctx, env)
824 if needsRegistration(err) {
825 if rerr := k.registerSender(ctx, to, from); rerr != nil {
826 return "", rerr
827 }
828 err = k.deliver(ctx, env)
829 }
830 if err != nil {
831 return "", err
832 }
833 return env.ID, nil
834}
835
836func (k *Keymail) deliver(ctx context.Context, env envelope) error {
837 _, err := k.do(ctx, http.MethodPost, "/api/federation/deliver", "", map[string]any{"envelope": env}, nil)
838 return err
839}
840
841// needsRegistration: the mailbox does not know this sender, or knows it
842// under a different key (the console's key was rotated). Both are cured by
843// registering, which upserts the key.
844func needsRegistration(err error) bool {
845 var ke *keymailError
846 if !errors.As(err, &ke) || ke.Status != http.StatusForbidden {
847 return false
848 }
849 return ke.Msg == "unknown_sender" || ke.Msg == "signing key does not match registered key"
850}
851
852func (k *Keymail) registerSender(ctx context.Context, to, from string) error {
853 signPub := k.SignPub()
854 var ch struct {
855 Challenge string `json:"challenge"`
856 Bits int `json:"bits"`
857 }
858 if _, err := k.do(ctx, http.MethodPost, "/api/federation/challenge", "",
859 map[string]string{"to": to, "from": from, "sign_pub": signPub}, &ch); err != nil {
860 return err
861 }
862 if ch.Bits > maxFederationPowBits {
863 return ErrFederationPriceTooHigh
864 }
865 signPubRaw, err := base64.StdEncoding.DecodeString(signPub)
866 if err != nil {
867 return err
868 }
869 nonce := sha256.Sum256([]byte(ch.Challenge))
870 counter, err := powMine(ctx, nonce[:], signPubRaw, ch.Bits)
871 if err != nil {
872 return fmt.Errorf("keymail: proof of work: %w", err)
873 }
874 boxPub, err := federationReplyBox()
875 if err != nil {
876 return err
877 }
878 att, err := signRaw(k.signKey, attestationMsg(from, signPub, boxPub))
879 if err != nil {
880 return err
881 }
882 _, err = k.do(ctx, http.MethodPost, "/api/federation/register", "", map[string]any{
883 "to": to, "challenge": ch.Challenge, "counter": counter, "from": from,
884 "sign_pub": signPub, "box_pub": boxPub, "attestation": att,
885 }, nil)
886 return err
887}
888```
889
890- [ ] **Step 7: Run the tests**
891
892Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestSendFederated|TestPow|Keymail|SendApp' -count=1`
893Expected: PASS.
894
895- [ ] **Step 8: Commit**
896
897```bash
898git add internal/console/keymail.go internal/console/keymailfed.go internal/console/keymailfed_test.go internal/console/keymail_test.go
899git commit -m "console: deliver to any Keymail mailbox by federation"
900```
901
902---
903
904### Task 3: The console mailer
905
906**Files:**
907- Create: `internal/mail/sessend.go`
908- Create: `internal/console/consolemail.go`
909- Modify: `internal/console/server.go` (`claimApp`)
910- Test: `internal/console/consolemail_test.go`
911
912**Interfaces:**
913- Consumes: `mail.EnsureDomain`, `mail.VerifyDomain`, `mail.TenantName`,
914 `mail.StatusVerified`, `release.OpsAccount`, `release.Store`,
915 `mail.Provisioner`, `dnsrec.Writer`, `mail.Deployment`.
916- Produces:
917 - `mail.EmailSender` interface: `SendEmail(ctx, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error)`
918 - `func mail.NewSESEmailSender(ctx context.Context, region string) (mail.EmailSender, error)`
919 - `type ConsoleMailer interface { Send(ctx context.Context, to, subject, body string) error }`
920 - `var ErrConsoleMailNotReady error`
921 - `const consoleMailApp = "console-mail"`
922 - `func ConsoleMailFrom(explicit, platformSuffix string) string` (exported: `cmd/console` calls it in Task 5)
923 - `func newSESConsoleMailer(from string, s release.Store, p mail.Provisioner, w dnsrec.Writer, d mail.Deployment, ses mail.EmailSender) *sesConsoleMailer`
924 - `func (m *sesConsoleMailer) Ensure(ctx context.Context) error`
925 - `func (m *sesConsoleMailer) Send(ctx context.Context, to, subject, body string) error`
926
927- [ ] **Step 1: Write `internal/mail/sessend.go`** (a seam and a
928 constructor, so no test of its own):
929
930```go
931package mail
932
933import (
934 "context"
935
936 "github.com/aws/aws-sdk-go-v2/aws"
937 awsconfig "github.com/aws/aws-sdk-go-v2/config"
938 "github.com/aws/aws-sdk-go-v2/service/sesv2"
939 sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
940)
941
942// EmailSender is the one SESv2 call the console makes to send as itself
943// (its invite fallback), narrowed so tests use a fake. Apps never use it:
944// they send by SMTP with their own credential.
945type EmailSender interface {
946 SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error)
947}
948
949// NewSESEmailSender builds a sender on the default credential chain, which
950// on a box is the host role.
951func NewSESEmailSender(ctx context.Context, region string) (EmailSender, error) {
952 cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
953 if err != nil {
954 return nil, err
955 }
956 return sesv2.NewFromConfig(cfg), nil
957}
958
959// SimpleEmailInput is a plain-text message attributed to tenant and, when
960// set, counted through configSet.
961func SimpleEmailInput(from, to, subject, body, tenant, configSet string) *sesv2.SendEmailInput {
962 in := &sesv2.SendEmailInput{
963 FromEmailAddress: aws.String(from),
964 Destination: &sesv2types.Destination{ToAddresses: []string{to}},
965 Content: &sesv2types.EmailContent{Simple: &sesv2types.Message{
966 Subject: &sesv2types.Content{Data: aws.String(subject), Charset: aws.String("UTF-8")},
967 Body: &sesv2types.Body{Text: &sesv2types.Content{Data: aws.String(body), Charset: aws.String("UTF-8")}},
968 }},
969 TenantName: aws.String(tenant),
970 }
971 if configSet != "" {
972 in.ConfigurationSetName = aws.String(configSet)
973 }
974 return in
975}
976```
977
978Run: `export GOCACHE=$TMPDIR/gocache; go build ./internal/mail`
979Expected: builds.
980
981- [ ] **Step 2: Write the failing tests** in `consolemail_test.go`:
982
983```go
984package console
985
986import (
987 "context"
988 "errors"
989 "testing"
990 "time"
991
992 "github.com/aws/aws-sdk-go-v2/aws"
993 "github.com/aws/aws-sdk-go-v2/service/sesv2"
994
995 "github.com/carlosframework/platform/internal/dnsrec"
996 "github.com/carlosframework/platform/internal/mail"
997 "github.com/carlosframework/platform/internal/release"
998)
999
1000type fakeSES struct {
1001 sent []*sesv2.SendEmailInput
1002 err error
1003}
1004
1005func (f *fakeSES) SendEmail(_ context.Context, in *sesv2.SendEmailInput, _ ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) {
1006 if f.err != nil {
1007 return nil, f.err
1008 }
1009 f.sent = append(f.sent, in)
1010 return &sesv2.SendEmailOutput{MessageId: aws.String("m-1")}, nil
1011}
1012
1013var verifiedStatus = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}
1014
1015func testMailDeployment() mail.Deployment {
1016 return mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.test", SNSTopicARN: "arn:aws:sns:eu-west-1:1:t"}
1017}
1018
1019func TestConsoleMailFrom(t *testing.T) {
1020 cases := []struct{ explicit, suffix, want string }{
1021 {"invites@tito.io", "oncarlos.test", "invites@tito.io"},
1022 {"", "oncarlos.test", "noreply@console.bab.oncarlos.test"},
1023 {"", "", ""},
1024 {" Invites@Tito.io ", "", "invites@tito.io"},
1025 }
1026 for _, c := range cases {
1027 if got := ConsoleMailFrom(c.explicit, c.suffix); got != c.want {
1028 t.Errorf("ConsoleMailFrom(%q, %q) = %q, want %q", c.explicit, c.suffix, got, c.want)
1029 }
1030 }
1031}
1032
1033func TestConsoleMailerSendsOnceVerified(t *testing.T) {
1034 ctx := context.Background()
1035 s := release.NewDirStore(t.TempDir())
1036 p := mail.NewMemProvisioner("1")
1037 p.StatusOnEnsure = verifiedStatus
1038 ses := &fakeSES{}
1039 m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
1040 if err := m.Ensure(ctx); err != nil {
1041 t.Fatal(err)
1042 }
1043 if err := m.Send(ctx, "bill@gmail.com", "subj", "body"); err != nil {
1044 t.Fatal(err)
1045 }
1046 if len(ses.sent) != 1 {
1047 t.Fatalf("sent %d, want 1", len(ses.sent))
1048 }
1049 in := ses.sent[0]
1050 if aws.ToString(in.FromEmailAddress) != "noreply@console.bab.oncarlos.test" ||
1051 in.Destination.ToAddresses[0] != "bill@gmail.com" ||
1052 aws.ToString(in.TenantName) != mail.TenantName(release.OpsAccount) ||
1053 aws.ToString(in.ConfigurationSetName) != mail.ConfigSetName(release.OpsAccount) {
1054 t.Fatalf("SendEmailInput = %+v", in)
1055 }
1056}
1057
1058// Review Focus 4.
1059func TestConsoleMailerRefusesUntilVerified(t *testing.T) {
1060 ctx := context.Background()
1061 s := release.NewDirStore(t.TempDir())
1062 p := mail.NewMemProvisioner("1") // identities stay PENDING
1063 ses := &fakeSES{}
1064 m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
1065 if err := m.Send(ctx, "bill@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
1066 t.Fatalf("Send before verification = %v, want ErrConsoleMailNotReady", err)
1067 }
1068 if len(ses.sent) != 0 {
1069 t.Fatal("sent through an unverified identity")
1070 }
1071}
1072
1073func TestConsoleMailerCachesAVerifiedIdentity(t *testing.T) {
1074 ctx := context.Background()
1075 s := release.NewDirStore(t.TempDir())
1076 p := mail.NewMemProvisioner("1")
1077 p.StatusOnEnsure = verifiedStatus
1078 ses := &fakeSES{}
1079 m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
1080 if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil {
1081 t.Fatal(err)
1082 }
1083 p.FailNext["IdentityStatus"] = errors.New("SES is down")
1084 if err := m.Send(ctx, "b@gmail.com", "s", "b"); err != nil {
1085 t.Fatalf("second send re-checked SES: %v", err)
1086 }
1087}
1088
1089func TestConsoleMailerRechecksPendingAfterFiveMinutes(t *testing.T) {
1090 ctx := context.Background()
1091 s := release.NewDirStore(t.TempDir())
1092 p := mail.NewMemProvisioner("1")
1093 ses := &fakeSES{}
1094 m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
1095 now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
1096 m.now = func() time.Time { return now }
1097 if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
1098 t.Fatalf("first = %v", err)
1099 }
1100 for k := range p.Identities {
1101 p.Identities[k].Status = verifiedStatus
1102 }
1103 if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
1104 t.Fatalf("within 5 minutes = %v, want the cached not-ready", err)
1105 }
1106 now = now.Add(5*time.Minute + time.Second)
1107 if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil {
1108 t.Fatalf("after 5 minutes = %v, want sent", err)
1109 }
1110}
1111
1112// Review Focus 5.
1113func TestConsoleMailAppIsReservedInOps(t *testing.T) {
1114 fx := newConsoleFixture(t)
1115 ops, err := fx.st.AccountByID(OpsAccountID)
1116 if err != nil {
1117 t.Fatal(err)
1118 }
1119 if got, _, _ := fx.srv.claimApp(context.Background(), ops, consoleMailApp, "paul@keymail.dev", "", true); got != claimReserved {
1120 t.Fatalf("claimApp(bab, %q) = %v, want claimReserved", consoleMailApp, got)
1121 }
1122}
1123```
1124
1125`mail.ConfigSetName` and `mail.TenantName` both return `"carlos-" + acct`.
1126If `MemProvisioner.Identities` values or their `Status` field differ from
1127`memprovisioner.go`, follow that file.
1128
1129- [ ] **Step 3: Run and watch them fail**
1130
1131Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestConsoleMail' -count=1`
1132Expected: FAIL to compile, `undefined: newSESConsoleMailer`.
1133
1134- [ ] **Step 4: Implement `consolemail.go`**
1135
1136```go
1137package console
1138
1139// The console's own email, for when Keymail cannot carry a message (today:
1140// invites to someone with no Keymail mailbox). The From identity is made by
1141// the platform's own mail layer, mail.EnsureDomain, owned by the ops
1142// account under the reserved referrer app consoleMailApp. Sends go out on
1143// the console's own AWS role, so no SMTP credential exists to store.
1144
1145import (
1146 "context"
1147 "errors"
1148 "fmt"
1149 "log"
1150 "strings"
1151 "sync"
1152 "time"
1153
1154 "github.com/carlosframework/platform/internal/dnsrec"
1155 "github.com/carlosframework/platform/internal/mail"
1156 "github.com/carlosframework/platform/internal/release"
1157)
1158
1159// consoleMailApp is the referrer name on the console's domain record. claimApp
1160// refuses it in the ops account, so no app delete can tear the identity down.
1161const consoleMailApp = "console-mail"
1162
1163// consoleMailPendingRecheck is how long a not-yet-verified answer is trusted.
1164const consoleMailPendingRecheck = 5 * time.Minute
1165
1166// ErrConsoleMailNotReady: the identity is not verified (or could not be
1167// made). The caller falls back to the next channel.
1168var ErrConsoleMailNotReady = errors.New("console mail: sending identity not verified yet")
1169
1170type ConsoleMailer interface {
1171 Send(ctx context.Context, to, subject, body string) error
1172}
1173
1174// ConsoleMailFrom resolves the From address: the operator's explicit choice,
1175// else noreply on the ops account's platform name, else "" (no email).
1176func ConsoleMailFrom(explicit, platformSuffix string) string {
1177 if e := strings.ToLower(strings.TrimSpace(explicit)); e != "" {
1178 return e
1179 }
1180 if platformSuffix == "" {
1181 return ""
1182 }
1183 return "noreply@console." + release.OpsAccount + "." + platformSuffix
1184}
1185
1186type sesConsoleMailer struct {
1187 from, domain string
1188 store release.Store
1189 prov mail.Provisioner
1190 dns dnsrec.Writer
1191 dep mail.Deployment
1192 ses mail.EmailSender
1193 now func() time.Time
1194
1195 mu sync.Mutex
1196 ensured bool
1197 verified bool
1198 configSet string
1199 checkedAt time.Time
1200}
1201
1202func newSESConsoleMailer(from string, s release.Store, p mail.Provisioner, w dnsrec.Writer, d mail.Deployment, ses mail.EmailSender) *sesConsoleMailer {
1203 _, domain, _ := strings.Cut(from, "@")
1204 return &sesConsoleMailer{from: from, domain: domain, store: s, prov: p, dns: w, dep: d, ses: ses, now: time.Now}
1205}
1206
1207// Ensure makes (or re-reads) the identity. Idempotent. Pending DNS records
1208// for a custom domain are logged for the operator to publish.
1209func (m *sesConsoleMailer) Ensure(ctx context.Context) error {
1210 m.mu.Lock()
1211 defer m.mu.Unlock()
1212 return m.ensureLocked(ctx)
1213}
1214
1215func (m *sesConsoleMailer) ensureLocked(ctx context.Context) error {
1216 if m.ensured {
1217 return nil
1218 }
1219 _, pending, err := mail.EnsureDomain(ctx, m.store, m.prov, m.dns, m.dep,
1220 release.OpsAccount, consoleMailApp, m.domain, m.now())
1221 if err != nil {
1222 return fmt.Errorf("console mail: ensure %s: %w", m.domain, err)
1223 }
1224 for _, r := range pending {
1225 log.Printf("console mail: publish %s %s %s so %s can verify", r.Type, r.Name, strings.Join(r.Values, " "), m.domain)
1226 }
1227 m.ensured = true
1228 return nil
1229}
1230
1231func (m *sesConsoleMailer) ready(ctx context.Context) (string, error) {
1232 m.mu.Lock()
1233 defer m.mu.Unlock()
1234 if m.verified {
1235 return m.configSet, nil
1236 }
1237 if !m.checkedAt.IsZero() && m.now().Sub(m.checkedAt) < consoleMailPendingRecheck {
1238 return "", ErrConsoleMailNotReady
1239 }
1240 m.checkedAt = m.now()
1241 if err := m.ensureLocked(ctx); err != nil {
1242 return "", fmt.Errorf("%w: %v", ErrConsoleMailNotReady, err)
1243 }
1244 rec, err := mail.VerifyDomain(ctx, m.store, m.prov, m.dep, release.OpsAccount, m.domain, m.dep.Region, m.now())
1245 if err != nil {
1246 return "", fmt.Errorf("%w: %v", ErrConsoleMailNotReady, err)
1247 }
1248 st := rec.Regions[m.dep.Region]
1249 if st == nil || st.Status != mail.StatusVerified {
1250 return "", ErrConsoleMailNotReady
1251 }
1252 m.verified, m.configSet = true, st.ConfigSet
1253 return m.configSet, nil
1254}
1255
1256func (m *sesConsoleMailer) Send(ctx context.Context, to, subject, body string) error {
1257 configSet, err := m.ready(ctx)
1258 if err != nil {
1259 return err
1260 }
1261 _, err = m.ses.SendEmail(ctx, mail.SimpleEmailInput(m.from, to, subject, body,
1262 mail.TenantName(release.OpsAccount), configSet))
1263 if err != nil {
1264 return fmt.Errorf("console mail: send to %s: %w", to, err)
1265 }
1266 return nil
1267}
1268```
1269
1270- [ ] **Step 5: Reserve the referrer in `claimApp`** (`server.go`, after
1271 the `"new"` check):
1272
1273```go
1274 // The console's own mail identity hangs off this name in the ops
1275 // account (consolemail.go). An app so named could be deleted, and an app
1276 // delete tears down every domain whose last referrer it was.
1277 if acct.Sqid == release.OpsAccount && name == consoleMailApp {
1278 return claimReserved, "", nil
1279 }
1280```
1281
1282- [ ] **Step 6: Run the tests**
1283
1284Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestConsoleMail' -count=1 && go test ./internal/mail -count=1`
1285Expected: PASS.
1286
1287- [ ] **Step 7: Commit**
1288
1289```bash
1290git add internal/mail/sessend.go internal/console/consolemail.go internal/console/consolemail_test.go internal/console/server.go
1291git commit -m "console: its own email sender on the platform's mail layer"
1292```
1293
1294---
1295
1296### Task 4: `handleInvite` delivers
1297
1298**Files:**
1299- Modify: `internal/console/server.go` (Config, Server, `handleInvite`)
1300- Modify: `internal/console/keymail_test.go` (`handleSend` enforces the
1301 one-recipient rule)
1302- Modify: `internal/console/server_account_test.go`
1303 (`TestInviteAcceptCreatesMembership` reads `fedBodies`)
1304- Test: `internal/console/invite_delivery_test.go`
1305
1306**Interfaces:**
1307- Consumes: `Keymail.SendFederated`, `ErrNoKeymailMailbox` (Task 2);
1308 `ConsoleMailer` (Task 3).
1309- Produces: `Server.sendFederated func(ctx context.Context, to, subject, body string) (string, error)`
1310 (defaults to `s.km.SendFederated`); `Server.consoleMail ConsoleMailer`
1311 (nil unless wired); `Config.ConsoleMail ConsoleMailer`.
1312
1313- [ ] **Step 1: Make the fake enforce Keymail's rule.** In `handleSend`,
1314 replace the "Any recipient is accepted" block with:
1315
1316```go
1317 // The grant authorizes exactly one recipient: the user who approved it
1318 // (keymail appapi.go handleAppSend). This fake used to accept anyone,
1319 // which is how invites shipped broken.
1320 grantUser := strings.TrimPrefix(f.token, "user.tok-")
1321 if e.To != grantUser {
1322 jsonErr(w, http.StatusForbidden, "envelope to must be "+grantUser)
1323 return
1324 }
1325```
1326
1327- [ ] **Step 2: Run the existing invite test and watch it fail.** This is
1328 the §1 regression:
1329
1330Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestInviteAcceptCreatesMembership' -count=1`
1331Expected: FAIL, `invite link not delivered through keymail`.
1332
1333Change its assertion to read federation deliveries:
1334
1335```go
1336 // The link went into sam's Keymail inbox by federation.
1337 sent := fx.fk.fedBodies()
1338```
1339
1340- [ ] **Step 3: Write the failing table test** in
1341 `invite_delivery_test.go`:
1342
1343```go
1344package console
1345
1346import (
1347 "context"
1348 "errors"
1349 "net/http"
1350 "net/url"
1351 "strings"
1352 "sync"
1353 "testing"
1354 "time"
1355)
1356
1357type fakeConsoleMail struct {
1358 mu sync.Mutex
1359 err error
1360 sent []string // to|body
1361}
1362
1363func (f *fakeConsoleMail) Send(_ context.Context, to, _, body string) error {
1364 f.mu.Lock()
1365 defer f.mu.Unlock()
1366 if f.err != nil {
1367 return f.err
1368 }
1369 f.sent = append(f.sent, to+"|"+body)
1370 return nil
1371}
1372
1373// flashAfter reads the flash cookie the last redirect set.
1374func flashAfter(t *testing.T, c *http.Client, base string) (kind, msg string) {
1375 t.Helper()
1376 u, _ := url.Parse(base)
1377 for _, ck := range c.Jar.Cookies(u) {
1378 if ck.Name == flashCookie {
1379 v, _ := url.QueryUnescape(ck.Value)
1380 kind, msg, _ = strings.Cut(v, "|")
1381 return kind, msg
1382 }
1383 }
1384 return "", ""
1385}
1386
1387func TestInviteDelivery(t *testing.T) {
1388 cases := []struct {
1389 name string
1390 to string
1391 keymail func(fx *consoleFixture)
1392 mail *fakeConsoleMail // nil: no email fallback configured
1393 wantKind string
1394 wantInMsg string // a fragment that identifies the outcome
1395 wantFed bool
1396 wantMail bool
1397 }{
1398 {name: "keymail delivers", to: "sam@keymail.dev", mail: &fakeConsoleMail{},
1399 wantKind: "ok", wantInMsg: "Keymail", wantFed: true},
1400 {name: "no mailbox, email delivers", to: "sam@gmail.com", mail: &fakeConsoleMail{},
1401 wantKind: "ok", wantInMsg: "emailed", wantMail: true},
1402 {name: "keymail refuses, email delivers", to: "sam@keymail.dev", mail: &fakeConsoleMail{},
1403 keymail: func(fx *consoleFixture) { fx.fk.blocked["console@"+OriginHost(fx.ts.URL)] = true },
1404 wantKind: "ok", wantInMsg: "emailed", wantMail: true},
1405 {name: "both fail", to: "sam@gmail.com", mail: &fakeConsoleMail{err: ErrConsoleMailNotReady},
1406 wantKind: "err", wantInMsg: "/invite/"},
1407 {name: "no email configured", to: "sam@gmail.com",
1408 wantKind: "err", wantInMsg: "/invite/"},
1409 }
1410 for _, c := range cases {
1411 t.Run(c.name, func(t *testing.T) {
1412 fx := newConsoleFixture(t)
1413 if c.keymail != nil {
1414 c.keymail(fx)
1415 }
1416 if c.mail != nil {
1417 fx.srv.consoleMail = c.mail
1418 }
1419 owner := newBrowser(t)
1420 signUp(t, fx, owner, "code-owner")
1421 csrf := sessionToken(t, fx, owner)
1422 st, _, _ := postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {c.to}})
1423 if st != http.StatusSeeOther {
1424 t.Fatalf("invite = %d", st)
1425 }
1426 kind, msg := flashAfter(t, owner, fx.ts.URL)
1427 if kind != c.wantKind || !strings.Contains(msg, c.wantInMsg) {
1428 t.Fatalf("flash = %q %q, want %q containing %q", kind, msg, c.wantKind, c.wantInMsg)
1429 }
1430 pending, _ := fx.st.PendingInvites(OpsAccountID)
1431 if len(pending) != 1 {
1432 t.Fatalf("invite row missing: %+v", pending)
1433 }
1434 link := "/invite/" + pending[0].Token
1435 if got := strings.Contains(strings.Join(fx.fk.fedBodies(), "\n"), link); got != c.wantFed {
1436 t.Errorf("delivered by keymail = %v, want %v", got, c.wantFed)
1437 }
1438 mailed := c.mail != nil && strings.Contains(strings.Join(c.mail.sent, "\n"), c.to+"|") &&
1439 strings.Contains(strings.Join(c.mail.sent, "\n"), link)
1440 if mailed != c.wantMail {
1441 t.Errorf("emailed = %v, want %v", mailed, c.wantMail)
1442 }
1443 })
1444 }
1445}
1446
1447// The §1 second fault: an owner with no stored grant used to get a false
1448// success. Delivery no longer reads the inviter's grant at all.
1449func TestInviteDeliversWithoutTheInvitersGrant(t *testing.T) {
1450 fx := newConsoleFixture(t)
1451 owner := newBrowser(t)
1452 signUp(t, fx, owner, "code-owner")
1453 if err := fx.st.SetGrantToken("paul@keymail.dev", "", time.Now()); err != nil {
1454 t.Fatal(err)
1455 }
1456 csrf := sessionToken(t, fx, owner)
1457 postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
1458 if len(fx.fk.fedBodies()) != 1 {
1459 t.Fatalf("fedBodies = %v, want one delivery", fx.fk.fedBodies())
1460 }
1461}
1462
1463// Review Focus 3: a Keymail that never answers must not swallow the email leg.
1464func TestInviteFallsBackWhenKeymailHangs(t *testing.T) {
1465 fx := newConsoleFixture(t)
1466 mailer := &fakeConsoleMail{}
1467 fx.srv.consoleMail = mailer
1468 fx.srv.inviteTimeout = 300 * time.Millisecond
1469 fx.srv.inviteKeymailShare = 150 * time.Millisecond
1470 fx.srv.sendFederated = func(ctx context.Context, _, _, _ string) (string, error) {
1471 <-ctx.Done()
1472 return "", ctx.Err()
1473 }
1474 owner := newBrowser(t)
1475 signUp(t, fx, owner, "code-owner")
1476 csrf := sessionToken(t, fx, owner)
1477 start := time.Now()
1478 postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
1479 if d := time.Since(start); d > 2*time.Second {
1480 t.Fatalf("invite took %v", d)
1481 }
1482 if len(mailer.sent) != 1 {
1483 t.Fatalf("email leg not tried after a hung keymail: %v", mailer.sent)
1484 }
1485 if kind, _ := flashAfter(t, owner, fx.ts.URL); kind != "ok" {
1486 t.Fatalf("flash kind = %q, want ok", kind)
1487 }
1488}
1489
1490var _ = errors.New
1491```
1492
1493Drop the `errors` keep-alive if unused.
1494
1495Review Focus 3 shapes the timeout. A hung Keymail must leave the email leg
1496some time, so Keymail gets its own share of the 20s budget:
1497`inviteKeymailShare` (default 12s) inside `inviteTimeout` (default 20s).
1498Both are Server fields so the test can shrink them.
1499
1500- [ ] **Step 4: Run and watch it fail**
1501
1502Run: `export GOCACHE=$TMPDIR/gocache; go test ./internal/console -run 'TestInvite' -count=1`
1503Expected: FAIL to compile (`fx.srv.consoleMail`, `sendFederated`,
1504`inviteTimeout` undefined).
1505
1506- [ ] **Step 5: Implement.** Add to `Config` (next to the mail fields):
1507
1508```go
1509 // ConsoleMail is the console's own email sender (consolemail.go), the
1510 // invite fallback when Keymail cannot deliver. nil: no email fallback.
1511 ConsoleMail ConsoleMailer
1512```
1513
1514Add to `Server`:
1515
1516```go
1517 // Invite delivery (handleInvite): Keymail federation first, then the
1518 // console's own email. Fields so tests can substitute and shrink them.
1519 sendFederated func(ctx context.Context, to, subject, body string) (string, error)
1520 consoleMail ConsoleMailer
1521 inviteTimeout time.Duration
1522 inviteKeymailShare time.Duration
1523```
1524
1525In `NewServer`, after `s.sendApp = s.km.SendApp`:
1526
1527```go
1528 s.sendFederated = s.km.SendFederated
1529```
1530
1531and in the struct literal: `consoleMail: cfg.ConsoleMail, inviteTimeout:
153220 * time.Second, inviteKeymailShare: 12 * time.Second,`.
1533
1534Replace the doc comment and the delivery half of `handleInvite` (from
1535`link := ...` to the end):
1536
1537```go
1538 link := s.cfg.Origin + "/invite/" + url.PathEscape(tok)
1539 subject, body := inviteMessage(addr, acct.Name, to, link)
1540 ctx, cancel := context.WithTimeout(r.Context(), s.inviteTimeout)
1541 defer cancel()
1542 kctx, kcancel := context.WithTimeout(ctx, s.inviteKeymailShare)
1543 _, err = s.sendFederated(kctx, to, subject, body)
1544 kcancel()
1545 if err == nil {
1546 back("ok", inviteFlashKeymail(to))
1547 return
1548 }
1549 log.Printf("console: invite to %s by keymail: %v", to, err)
1550 if s.consoleMail != nil {
1551 merr := s.consoleMail.Send(ctx, to, subject, body)
1552 if merr == nil {
1553 back("ok", inviteFlashEmail(to))
1554 return
1555 }
1556 log.Printf("console: invite to %s by email: %v", to, merr)
1557 }
1558 back("err", inviteFlashFailed(to, link))
1559}
1560```
1561
1562The new doc comment on `handleInvite`:
1563
1564```go
1565// handleInvite creates a single-use invitation and delivers its link:
1566// Keymail federation first (into the invitee's main inbox, as the console
1567// itself, no grant involved), then the console's own email, then the link
1568// shown to the owner to pass on. The invite row exists whichever way it
1569// went, so a failed delivery is a flash, not a rollback.
1570```
1571
1572Add the copy helpers below `handleInvite`, using **Task 0's approved
1573text verbatim** in place of these drafts:
1574
1575```go
1576func inviteMessage(inviter, account, to, link string) (subject, body string) {
1577 return "You've been invited to an account on the CARLOS platform",
1578 inviter + " invited you to the account \"" + account + "\" on the CARLOS platform.\n\n" +
1579 "Open this link while signed in as " + to + " to accept. It works once and expires in 7 days:\n\n" +
1580 link + "\n\nIf you weren't expecting this, ignore it — nothing happens without the link."
1581}
1582
1583func inviteFlashKeymail(to string) string { return "Invited " + to + ". The link is in their Keymail inbox." }
1584func inviteFlashEmail(to string) string { return "Invited " + to + ". We emailed them the link." }
1585func inviteFlashFailed(to, link string) string {
1586 return "Invited " + to + ", but delivering the link failed — you can send it on yourself: " + link
1587}
1588```
1589
1590If the approved texts no longer contain "Keymail", "emailed" or the link,
1591update `wantInMsg` in the table to fragments of the approved text.
1592
1593- [ ] **Step 6: Run the console package**
1594
1595Run: `export GOCACHE=$TMPDIR/gocache; TMPDIR=/tmp/g go test ./internal/console -count=1`
1596(create `/tmp/g` first if missing: `mkdir -p /tmp/g`)
1597Expected: PASS. If another test sent an app message to someone other than
1598the grant user, it was relying on the old fake. Fix the test, not the fake.
1599
1600- [ ] **Step 7: Commit**
1601
1602```bash
1603git add internal/console/server.go internal/console/keymail_test.go internal/console/server_account_test.go internal/console/invite_delivery_test.go
1604git commit -m "console: invites deliver by keymail federation, then email, then by hand"
1605```
1606
1607---
1608
1609### Task 5: Wiring in `cmd/console`
1610
1611**Files:**
1612- Modify: `cmd/console/main.go`
1613- Test: the existing `cmd/console` test file holding the `mailBackends`
1614 tests (`grep -ln mailBackends cmd/console/*_test.go`)
1615
1616**Interfaces:**
1617- Consumes: `console.ConsoleMailer`; `mail.NewSESEmailSender`. The
1618 constructor must be exported for `cmd/console`, so in
1619 `internal/console/consolemail.go` add:
1620
1621```go
1622// NewConsoleMailer builds the console's mailer, or nil when this deployment
1623// has no From address or no mail provisioner. The returned func runs the
1624// identity ensure; serve() starts it in the background.
1625func NewConsoleMailer(from string, s release.Store, p mail.Provisioner, w dnsrec.Writer, d mail.Deployment, ses mail.EmailSender) (ConsoleMailer, func(context.Context)) {
1626 if from == "" || p == nil || ses == nil || s == nil {
1627 return nil, func(context.Context) {}
1628 }
1629 m := newSESConsoleMailer(from, s, p, w, d, ses)
1630 return m, func(ctx context.Context) {
1631 if err := m.Ensure(ctx); err != nil {
1632 log.Printf("console mail: %v (retried on the next send)", err)
1633 }
1634 }
1635}
1636```
1637
1638- 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`.
1639
1640- [ ] **Step 1: Write the failing test** next to the `mailBackends`
1641 tests:
1642
1643```go
1644func TestConsoleMailerWiring(t *testing.T) {
1645 sesOK := func(context.Context, string) (mail.EmailSender, error) { return fakeSESClient{}, nil }
1646 dep := mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.test"}
1647 store := release.NewDirStore(t.TempDir())
1648 prov := mail.NewMemProvisioner("1")
1649
1650 t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "")
1651 if m, _ := consoleMailer(dep, store, prov, nil, sesOK); m == nil {
1652 t.Error("suffix set, no explicit From: want a mailer on the default address")
1653 }
1654 if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK); m != nil {
1655 t.Error("no suffix, no explicit From: want no mailer")
1656 }
1657 if m, _ := consoleMailer(dep, store, nil, nil, sesOK); m != nil {
1658 t.Error("no provisioner: want no mailer")
1659 }
1660 t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "invites@tito.io")
1661 if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK); m == nil {
1662 t.Error("explicit From, no suffix: want a mailer")
1663 }
1664 sesFail := func(context.Context, string) (mail.EmailSender, error) { return nil, errors.New("no creds") }
1665 if m, _ := consoleMailer(dep, store, prov, nil, sesFail); m != nil {
1666 t.Error("SES client construction failed: want no mailer, not a crash")
1667 }
1668}
1669
1670type fakeSESClient struct{}
1671
1672func (fakeSESClient) SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) {
1673 return &sesv2.SendEmailOutput{}, nil
1674}
1675```
1676
1677Add `errors`, `sesv2`, `release` and `mail` imports to the test file as
1678needed.
1679
1680- [ ] **Step 2: Run and watch it fail**
1681
1682Run: `export GOCACHE=$TMPDIR/gocache; go test ./cmd/console -run TestConsoleMailerWiring -count=1`
1683Expected: FAIL to compile, `undefined: consoleMailer`.
1684
1685- [ ] **Step 3: Implement** in `cmd/console/main.go`, below `mailBackends`:
1686
1687```go
1688// consoleMailer builds the console's own email sender, the invite fallback
1689// when Keymail cannot deliver (internal/console/consolemail.go). From is
1690// CARLOS_CONSOLE_MAIL_FROM, else noreply on the ops account's platform name.
1691// Like mailBackends, nothing here is fatal: no From, no provisioner or no
1692// SES client leaves invites on Keymail and the hand-off link.
1693func consoleMailer(dep mail.Deployment, releases release.Store, p mail.Provisioner, w dnsrec.Writer,
1694 newSES func(context.Context, string) (mail.EmailSender, error)) (console.ConsoleMailer, func(context.Context)) {
1695 from := console.ConsoleMailFrom(os.Getenv("CARLOS_CONSOLE_MAIL_FROM"), dep.PlatformSuffix)
1696 if from == "" || p == nil {
1697 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")
1698 return nil, func(context.Context) {}
1699 }
1700 ses, err := newSES(context.Background(), dep.Region)
1701 if err != nil {
1702 log.Printf("console: no console email, SES client unavailable: %v", err)
1703 return nil, func(context.Context) {}
1704 }
1705 return console.NewConsoleMailer(from, releases, p, w, dep, ses)
1706}
1707```
1708
1709In `serve()`, after `mailBackends` builds `mailProvisioner, dnsWriter` and
1710before `console.NewServer`:
1711
1712```go
1713 consoleMail, ensureConsoleMail := consoleMailer(mailDep, releases, mailProvisioner, dnsWriter, mail.NewSESEmailSender)
1714```
1715
1716(use whatever variable already holds the releases store passed as
1717`Releases:` in the Config literal), set `ConsoleMail: consoleMail,` in the
1718Config literal, and next to where `StartAlertWatcher` is started:
1719
1720```go
1721 go ensureConsoleMail(ctx)
1722```
1723
1724using the same context `StartAlertWatcher` gets.
1725
1726- [ ] **Step 4: Run**
1727
1728Run: `export GOCACHE=$TMPDIR/gocache; go test ./cmd/console ./internal/console -count=1 -run 'ConsoleMail|MailBackends'`
1729Expected: PASS.
1730
1731- [ ] **Step 5: Commit**
1732
1733```bash
1734git add cmd/console/main.go cmd/console/*_test.go internal/console/consolemail.go internal/console/consolemail_test.go
1735git commit -m "cmd/console: wire the console's email sender"
1736```
1737
1738---
1739
1740### Task 6: The SES send grant
1741
1742**Files:**
1743- Modify: `infra/modules/region-host/variables.tf`
1744- Modify: `infra/modules/region-host/main.tf`
1745- Modify: `infra/modules/region-host/README.md` (the variables table, if
1746 it has one)
1747
1748- [ ] **Step 1: Add the variables**
1749
1750```hcl
1751variable "console_mail_from" {
1752 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."
1753 type = string
1754 default = ""
1755}
1756
1757variable "console_mail_region" {
1758 description = "SES region the console sends from (CARLOS_MAIL_REGION). Required when console_mail_from is set."
1759 type = string
1760 default = ""
1761}
1762```
1763
1764- [ ] **Step 2: Add the policy** to `main.tf` after `host_store`:
1765
1766```hcl
1767# The console's own email (invite fallback, console-invite-delivery spec §4).
1768# One identity, one From address. The configuration set and tenant are the
1769# ops account's ("carlos-bab"), which SES checks on a tenant-attributed send.
1770resource "aws_iam_role_policy" "host_console_mail" {
1771 count = var.console_mail_from == "" ? 0 : 1
1772 name = "${var.iam_name}-console-mail"
1773 role = aws_iam_role.host.id
1774 policy = jsonencode({
1775 Version = "2012-10-17"
1776 Statement = [{
1777 Effect = "Allow"
1778 Action = ["ses:SendEmail"]
1779 Resource = [
1780 "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:identity/${split("@", var.console_mail_from)[1]}",
1781 "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:configuration-set/carlos-bab",
1782 "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:tenant/carlos-bab/*",
1783 ]
1784 Condition = {
1785 StringEquals = { "ses:FromAddress" = var.console_mail_from }
1786 }
1787 }]
1788 })
1789}
1790```
1791
1792`mail.ConfigSetName("bab")` and `mail.TenantName("bab")` are both
1793`"carlos-bab"` (`internal/mail/walk.go:412`, `internal/mail/policy.go:84`).
1794
1795- [ ] **Step 3: Validate**
1796
1797Run: `cd infra/modules/region-host && tofu init -backend=false >/dev/null && tofu validate`
1798Expected: `Success! The configuration is valid.` If `tofu` is not on PATH,
1799say so in the task report rather than skipping silently.
1800
1801- [ ] **Step 4: Commit**
1802
1803```bash
1804git add infra/modules/region-host
1805git commit -m "region-host: optional ses:SendEmail grant for the console's own From"
1806```
1807
1808---
1809
1810### Task 7: Whole-branch gate
1811
1812- [ ] **Step 1: Full tests and cross-builds**
1813
1814```bash
1815export GOCACHE=$TMPDIR/gocache; mkdir -p /tmp/g
1816TMPDIR=/tmp/g go test ./... -count=1 2>&1 | tail -30
1817GOOS=darwin go build ./... && GOOS=windows go build ./... && go vet ./internal/console ./internal/mail ./cmd/console
1818```
1819
1820Expected: all PASS, builds clean.
1821
1822- [ ] **Step 2: Document the variable** wherever `CARLOS_MAIL_REGION` is
1823 documented for operators (`grep -rn CARLOS_MAIL_REGION docs README.md`).
1824 If nowhere, add one paragraph to the console section of README.md naming
1825 `CARLOS_CONSOLE_MAIL_FROM`, its default and the `region-host` variables.
1826 Paul's copy review covers README prose, so run the copy-review skill on
1827 that paragraph before committing it.
1828
1829- [ ] **Step 3: Push and describe**
1830
1831```bash
1832git push origin design-console-invite-delivery
1833amadan 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
1834```
1835
1836Merging is Paul's call (`amadan branch merge carlos/platform
1837design-console-invite-delivery -squash -expect <full sha>`), after the
1838final review.
1839