carlos / platform Public
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-deliveryon branchdesign-console-invite-delivery; nevergit checkout -- <file>and never baregit stash. - Go builds from this sandbox need
export GOCACHE=$TMPDIR/gocachein the same command; socket tests needTMPDIR=/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 hashSHA-256(nonce || sign_pub_raw || counter_be64)withnonce = SHA-256(challenge_token)(keymail/internal/pow,keymail/federation.gohandleRegister). maxFederationPowBits = 24.- Federation From address:
console@+OriginHost(origin), the same FromSendAppuses. - The console's mail identity lives under account
release.OpsAccount("bab") with referrer app nameconsole-mail, whichclaimApprefuses inbab. - From resolution:
CARLOS_CONSOLE_MAIL_FROMif set; elsenoreply@console.bab.<CARLOS_MAIL_PLATFORM_SUFFIX>if the suffix is set; else no email fallback. No email fallback either whencfg.MailProvisioneris nil. handleInviteuses 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
- 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.SendFederatedtreats that 403 likeunknown_sender: register (full price) and retry once. Test in Task 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
ErrNoKeymailMailboxwhatever the status. Test in Task 2. - 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.
- The identity is not verified yet (fresh deployment, DNS still converging). A person expects the hand-off link, not a 500.
SendreturnsErrConsoleMailNotReady,handleInvitefalls through. Tests in Tasks 3 and 4. - Someone creates an app named
console-mailinbaband deletes it. A person expects the console's mail identity to survive. Creation is refused atclaimApp. 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 intosealEnvelope, 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,claimAppreservation,handleInvite. - Modify
cmd/console/main.go: readCARLOS_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.jsonin the worktree and commit it:
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
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
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
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); existingsealToBox,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 }withError() 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. Inkeymail.go, replace the non-200 branch so the message text stays byte-identical:
// 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():
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
sealEnvelopefromSendApp(same file), and makeSendAppcall it. Behaviour is unchanged:
// 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:
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, nilRun: 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 andpowBits: 8innewFakeKeymail, and register the routes:
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:
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:
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:
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
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
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.EmailSenderinterface: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/consolecalls 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):
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:
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
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):
// 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
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(handleSendenforces the one-recipient rule) - Modify:
internal/console/server_account_test.go(TestInviteAcceptCreatesMembershipreadsfedBodies) - 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 tos.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:
// 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:
// 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:
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.NewDrop 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):
// ConsoleMail is the console's own email sender (consolemail.go), the
// invite fallback when Keymail cannot deliver. nil: no email fallback.
ConsoleMail ConsoleMailerAdd to Server:
// 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.DurationIn NewServer, after s.sendApp = s.km.SendApp:
s.sendFederated = s.km.SendFederatedand 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):
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:
// 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:
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
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/consoletest file holding themailBackendstests (grep -ln mailBackends cmd/console/*_test.go)
Interfaces:
- Consumes:
console.ConsoleMailer;mail.NewSESEmailSender. The constructor must be exported forcmd/console, so ininternal/console/consolemail.goadd:
// 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))incmd/console/main.go.
- [ ] Step 1: Write the failing test next to the
mailBackendstests:
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, belowmailBackends:
// 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:
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 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
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
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.tfafterhost_store:
# 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
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
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/consoleExpected: all PASS, builds clean.
- [ ] Step 2: Document the variable wherever
CARLOS_MAIL_REGIONis documented for operators (grep -rn CARLOS_MAIL_REGION docs README.md). If nowhere, add one paragraph to the console section of README.md namingCARLOS_CONSOLE_MAIL_FROM, its default and theregion-hostvariables. Paul's copy review covers README prose, so run the copy-review skill on that paragraph before committing it.
- [ ] Step 3: Push and describe
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-deliveryMerging is Paul's call (amadan branch merge carlos/platform design-console-invite-delivery -squash -expect <full sha>), after the final review.