| 1 | # Console invite delivery: Keymail federation, then email |
| 2 | |
| 3 | Status: design, awaiting review (2026-09-28). |
| 4 | Follow-on: email magic-link + passkey sign-in gets its own spec and reuses |
| 5 | the console email sender built here (§4). |
| 6 | |
| 7 | ## 1. The bug |
| 8 | |
| 9 | An account owner invites `bill@keymail.dev` from the Team page and gets: |
| 10 | |
| 11 | > Invited bill@keymail.dev, but delivering the link failed — you can send it |
| 12 | > on yourself: https://…/invite/… |
| 13 | |
| 14 | Tito's console journal (2026-09-28 14:09 UTC) has the cause: |
| 15 | |
| 16 | ``` |
| 17 | console: invite send to bill@keymail.dev: keymail: POST /api/app/send: envelope to must be paul@keymail.dev |
| 18 | ``` |
| 19 | |
| 20 | `handleInvite` (`internal/console/server.go`) sends the invite through |
| 21 | `Keymail.SendApp` using the **inviter's** OAuth grant. Keymail's app API |
| 22 | lets a grant deliver to exactly one recipient, the user who approved it |
| 23 | (`keymail/appapi.go` `handleAppSend`: "The grant authorizes exactly one |
| 24 | recipient"). So every invite to anyone but yourself has failed since the |
| 25 | invite shipped in 35602776 (2026-08-02), on every deployment. The test fake |
| 26 | in `keymail_test.go` does not enforce that rule, which is why nothing caught |
| 27 | it. |
| 28 | |
| 29 | A second fault in the same handler: an inviter with no stored grant token |
| 30 | sends nothing and still gets the success flash ("the link is in their |
| 31 | Keymail Apps inbox"). |
| 32 | |
| 33 | ## 2. What it should do |
| 34 | |
| 35 | Deliver the invite by Keymail if the invitee has a Keymail mailbox, by |
| 36 | email if not, and fall back to the hand-off link only when neither works. |
| 37 | The flash says which one happened. |
| 38 | |
| 39 | Out of scope: the other `SendApp` callers (sign-in confirmation, CLI |
| 40 | sign-in notice, alert watcher). They all send to the grant's own user, |
| 41 | which Keymail allows. |
| 42 | |
| 43 | ## 3. Keymail delivery by federation |
| 44 | |
| 45 | Keymail already has a way for a sender it does not know to reach any |
| 46 | mailbox: the federation protocol (`keymail/federation.go`). It is what one |
| 47 | Keymail instance uses to write to another, and it charges an unknown sender |
| 48 | a proof of work instead of requiring a grant. The console uses it as a |
| 49 | sender in its own name. |
| 50 | |
| 51 | ### 3.1 Identity |
| 52 | |
| 53 | - **From** `console@<origin host>` (e.g. `console@console.platform.tito.io`), |
| 54 | the same From `SendApp` already pins. |
| 55 | - **Signing key**: the console's existing long-lived envelope key |
| 56 | (`Keymail.signKey`). Keymail registers a sender by (address, signing |
| 57 | key); the same key on every send means the proof of work is paid once |
| 58 | per recipient mailbox, and every later send to that mailbox is free. |
| 59 | - **Box key**: registration also records a key for replies to be sealed |
| 60 | to. Nobody replies to an invite and the console serves no Keymail |
| 61 | endpoints, so the console generates a P-256 ECDH key per process and |
| 62 | never keeps the private half. A later re-registration with a different |
| 63 | box key is harmless: Keymail keys free passage on the signing key. |
| 64 | |
| 65 | ### 3.2 Flow: `Keymail.SendFederated(ctx, to, subject, body) (msgID, error)` |
| 66 | |
| 67 | All calls go to the configured Keymail base URL (`k.base`). On |
| 68 | keymail.dev that is the relay, which routes every federation verb to the |
| 69 | recipient's mailbox by address (`keymail/relay.go` `route`). |
| 70 | |
| 71 | 1. `GET /api/federation/lookup?addr=<to>&from=<from>` → `box_pub`, |
| 72 | `pow_bits`. A non-200 here returns `ErrNoKeymailMailbox`, wrapping |
| 73 | Keymail's message. The relay answers 400 "could not determine target |
| 74 | mailbox" for an address it cannot resolve and 404 "no such mailbox" |
| 75 | for an unknown local part. |
| 76 | 2. Seal `{subject, body}` to `box_pub` and sign the envelope, exactly as |
| 77 | `SendApp` does today. Factor the shared seal-and-sign into one helper. |
| 78 | 3. `POST /api/federation/deliver {envelope}`. 200 means delivered. |
| 79 | 4. On 403 `{"error":"unknown_sender","pow_bits":N}`: |
| 80 | - If `N > maxFederationPowBits` (24), fail with |
| 81 | `ErrFederationPriceTooHigh`. A mailbox can set its own price, and the |
| 82 | console must not burn an HTTP request's worth of CPU on a hostile one. |
| 83 | - `POST /api/federation/challenge {to, from, sign_pub}` → |
| 84 | `{challenge, bits}`. |
| 85 | - Mine: find `counter` with |
| 86 | `SHA-256(SHA-256(challenge) || sign_pub_raw || counter_be64)` having |
| 87 | ≥ `bits` leading zero bits (`keymail/internal/pow`). A port of |
| 88 | `pow.Mine` of about 30 lines, cancelled by `ctx`. At 20 bits this is |
| 89 | about a million hashes, well under a second. |
| 90 | - `POST /api/federation/register {to, challenge, counter, from, |
| 91 | sign_pub, box_pub, attestation}`, where attestation is the signing |
| 92 | key's signature over `attestationMsg(from, sign_pub, box_pub)`. |
| 93 | - Retry step 3 once. |
| 94 | 5. Any other non-200 is an error carrying Keymail's text. A 403 "blocked" |
| 95 | is an error like any other: the recipient has blocked the console. |
| 96 | |
| 97 | The invite lands in the recipient's main inbox, not Apps. That is the |
| 98 | right place for a message from someone who is not an app the recipient |
| 99 | granted. |
| 100 | |
| 101 | The protocol constants and message shapes (`attestationMsg`, the pow hash |
| 102 | layout, `envSigContext`) are copied from the Keymail source with a comment |
| 103 | naming the file, as `keymail.go` already does for the envelope. |
| 104 | |
| 105 | ## 4. The console email sender |
| 106 | |
| 107 | ### 4.1 Where the From address comes from |
| 108 | |
| 109 | The identity is created through the platform's own mail layer, the same |
| 110 | `mail.EnsureDomain` walk apps use. It is owned by the built-in ops |
| 111 | account `bab` (`internal/release/qualified.go`), with the referrer app |
| 112 | name `console`. The send uses the console's own AWS role. No SMTP |
| 113 | credential exists, so nothing secret is stored. |
| 114 | |
| 115 | Config, read in `cmd/console/main.go` next to the other `CARLOS_MAIL_*` |
| 116 | variables: |
| 117 | |
| 118 | | Variable | Meaning | |
| 119 | |---|---| |
| 120 | | `CARLOS_CONSOLE_MAIL_FROM` | Full From address. Optional. | |
| 121 | |
| 122 | - Set: use it. Its domain goes through `EnsureDomain` as whatever |
| 123 | `DomainKindFor` says. A custom domain comes back with pending records |
| 124 | that the operator publishes, exactly as for an account's custom domain. |
| 125 | - Unset, `CARLOS_MAIL_PLATFORM_SUFFIX` set: `noreply@console.bab.<suffix>`. |
| 126 | This is a platform name owned by `bab`, since `platformNameOwnedBy` |
| 127 | wants the sqid as the label left of the suffix. The DNS writer |
| 128 | publishes its records automatically. |
| 129 | - Unset, no suffix, or `CARLOS_MAIL_REGION` unset (no provisioner): no |
| 130 | email fallback. The console logs this once at startup and invites fall |
| 131 | back to the hand-off link, as today. |
| 132 | |
| 133 | ### 4.2 Lifecycle |
| 134 | |
| 135 | - **Startup**: a background goroutine runs `EnsureDomain(bab, "console", |
| 136 | domain)`. It is idempotent: `EnsureIdentity` reads the identity back |
| 137 | rather than minting new DKIM tokens. It logs pending records for a |
| 138 | custom domain. A failure is logged and retried on the next send |
| 139 | attempt, never fatal, matching how `mailBackends` degrades. |
| 140 | - **Send**: `ConsoleMailer.Send(ctx, to, subject, body)`. |
| 141 | 1. Check `IdentityStatus`. Anything but verified returns |
| 142 | `ErrConsoleMailNotReady`. SES would refuse the send anyway; checking |
| 143 | first gives a clear log line. Cache a verified result for the |
| 144 | process lifetime and anything else for 5 minutes. |
| 145 | 2. SESv2 `SendEmail` with plain-text `Simple` content, `FromEmailAddress` |
| 146 | = the From address, `TenantName = mail.TenantName("bab")`, and |
| 147 | `ConfigurationSetName` = the domain record's config set when one |
| 148 | exists, so bounces and complaints are counted like any sender's. |
| 149 | - **Seam**: `ConsoleMailer` is an interface with one `Send` method. The |
| 150 | SES implementation takes a narrow SESv2 client interface, so tests use a |
| 151 | fake. |
| 152 | |
| 153 | ### 4.3 Guarding the `console` referrer |
| 154 | |
| 155 | The domain record names `console` as a referring app in `bab`. Any |
| 156 | teardown path that walks `bab`'s app domains must not delete this domain |
| 157 | if an app literally named `console` is deleted from `bab`. The plan's |
| 158 | first task checks `mail.Teardown` and the domain-detach paths and either |
| 159 | reserves the name `console` in `bab` for apps, or records the referrer |
| 160 | under a name `release.ValidApp` accepts but app creation refuses. The |
| 161 | plan picks one after reading the code; the spec only requires that no |
| 162 | user action on `bab` can tear down the console's identity. |
| 163 | |
| 164 | ### 4.4 IAM (`infra/modules/region-host`) |
| 165 | |
| 166 | New optional variable `console_mail_identity_arns` (list, default empty). |
| 167 | When non-empty, the host role gets: |
| 168 | |
| 169 | ``` |
| 170 | ses:SendEmail |
| 171 | Resource: those identity ARNs (+ the config set ARN pattern for the deployment) |
| 172 | Condition: StringLike ses:FromAddress = the configured From address |
| 173 | ``` |
| 174 | |
| 175 | The live deployments' existing SES/IAM rights for the provisioner were |
| 176 | granted outside this repo (nothing in `infra/modules` mentions `ses:`). |
| 177 | The plan records where each deployment's grants live before adding to |
| 178 | them. Flagship is `keymail` (271376211898). Tito is `titogo` |
| 179 | (665106592142), and a tofu apply there needs Paul's go. That apply can |
| 180 | ride with the store-boundary IAM that already blocks Tito's console |
| 181 | upgrade. |
| 182 | |
| 183 | ## 5. `handleInvite`, rewritten |
| 184 | |
| 185 | ``` |
| 186 | tok := CreateInvite(...) // unchanged |
| 187 | link := origin + "/invite/" + tok |
| 188 | |
| 189 | ctx 20s |
| 190 | err := km.SendFederated(ctx, to, subj, body) // Keymail first |
| 191 | if err == nil → flash ok "…in their Keymail inbox" |
| 192 | log err |
| 193 | if mailer != nil: |
| 194 | err2 := mailer.Send(ctx, to, subj, body) // email second |
| 195 | if err2 == nil → flash ok "…emailed to <to>" |
| 196 | log err2 |
| 197 | flash err "…delivering the link failed… <link>" // unchanged fallback |
| 198 | ``` |
| 199 | |
| 200 | - Email is tried after **any** Keymail failure, not only "no mailbox". |
| 201 | The invite link is bound to the invitee's signed-in identity (accepting |
| 202 | it requires being signed in as `to`), so plain email does not weaken |
| 203 | it. Delivery matters more here than sealing. |
| 204 | - The inviter's grant is no longer consulted, which also fixes the false |
| 205 | success for an inviter with no grant. |
| 206 | - The `ValidAddress` gate on the invite form stays as it is. Widening |
| 207 | who can be invited belongs to the sign-in spec. |
| 208 | - Worst case, the request waits for a Keymail timeout followed by an SES |
| 209 | call. The shared 20s context bounds it. |
| 210 | |
| 211 | ## 6. Copy (goes through copy review before it is written) |
| 212 | |
| 213 | Drafts only. The copy-review skill runs over these before any lands in a |
| 214 | file: |
| 215 | |
| 216 | 1. Flash, Keymail delivered: "Invited {to}. The link is in their Keymail |
| 217 | inbox." |
| 218 | 2. Flash, emailed: "Invited {to}. We emailed them the link." |
| 219 | 3. Flash, both failed: unchanged ("Invited {to}, but delivering the link |
| 220 | failed — you can send it on yourself: {link}"). It has an em dash, so |
| 221 | review it too. |
| 222 | 4. Message subject: unchanged ("You've been invited to an account on the |
| 223 | CARLOS platform"). |
| 224 | 5. Message body: unchanged text, reviewed as one string because email now |
| 225 | carries it. |
| 226 | |
| 227 | ## 7. Testing |
| 228 | |
| 229 | - **Keymail fake** (`keymail_test.go`): `/api/app/send` enforces |
| 230 | `to == grant user` and answers 403 with Keymail's message otherwise. |
| 231 | This is the regression test for §1. It also gains lookup (resolvable |
| 232 | and unresolvable addresses), challenge, register and deliver, with a |
| 233 | real pow check at 8 bits, a sender registry keyed by (from, sign_pub), |
| 234 | and a configurable price. |
| 235 | - **`SendFederated`**: |
| 236 | - a known sender delivers without pow; |
| 237 | - an unknown sender mines, registers and delivers; |
| 238 | - a price above the cap is refused without mining; |
| 239 | - a blocked sender gets an error; |
| 240 | - an unresolvable address gets `ErrNoKeymailMailbox`; |
| 241 | - the delivered envelope verifies and unseals. |
| 242 | - **`ConsoleMailer`** with a fake SESv2 client: |
| 243 | - unverified → `ErrConsoleMailNotReady` and no send; |
| 244 | - verified → one `SendEmail` with tenant, config set and From set; |
| 245 | - the verified cache holds. |
| 246 | - **`handleInvite`**, table-driven over: |
| 247 | - Keymail ok; |
| 248 | - no mailbox + email ok; |
| 249 | - Keymail error + email ok; |
| 250 | - both fail; |
| 251 | - no mailer + Keymail fails; |
| 252 | - inviter with no grant still delivers. |
| 253 | |
| 254 | Each case asserts the flash kind and text, and that the invite row |
| 255 | exists either way. |
| 256 | - **Config**: the From resolution table in §4.1. |
| 257 | - `GOOS=darwin` and `GOOS=windows` builds stay green (cross-compile gate). |
| 258 | |
| 259 | ## 8. Rollout |
| 260 | |
| 261 | 1. Merge, then roll consoles by the normal recipe. Keymail federation |
| 262 | works immediately with no infra change, so the §1 bug is fixed as soon |
| 263 | as a console runs the build. |
| 264 | 2. Flagship: apply the region-host variable, restart the console, check |
| 265 | the identity verifies, and invite a non-Keymail address. |
| 266 | 3. Tito: blocked on the store-boundary IAM (Tito's console is pinned on |
| 267 | cf02a4d5). Bundle the SES grant with that apply, with Paul's go. |
| 268 | |