# Console invite delivery: Keymail federation, then email

Status: design, awaiting review (2026-09-28).
Follow-on: email magic-link + passkey sign-in gets its own spec and reuses
the console email sender built here (§4).

## 1. The bug

An account owner invites `bill@keymail.dev` from the Team page and gets:

> Invited bill@keymail.dev, but delivering the link failed — you can send it
> on yourself: https://…/invite/…

Tito's console journal (2026-09-28 14:09 UTC) has the cause:

```
console: invite send to bill@keymail.dev: keymail: POST /api/app/send: envelope to must be paul@keymail.dev
```

`handleInvite` (`internal/console/server.go`) sends the invite through
`Keymail.SendApp` using the **inviter's** OAuth grant. Keymail's app API
lets a grant deliver to exactly one recipient, the user who approved it
(`keymail/appapi.go` `handleAppSend`: "The grant authorizes exactly one
recipient"). So every invite to anyone but yourself has failed since the
invite shipped in 35602776 (2026-08-02), on every deployment. The test fake
in `keymail_test.go` does not enforce that rule, which is why nothing caught
it.

A second fault in the same handler: an inviter with no stored grant token
sends nothing and still gets the success flash ("the link is in their
Keymail Apps inbox").

## 2. What it should do

Deliver the invite by Keymail if the invitee has a Keymail mailbox, by
email if not, and fall back to the hand-off link only when neither works.
The flash says which one happened.

Out of scope: the other `SendApp` callers (sign-in confirmation, CLI
sign-in notice, alert watcher). They all send to the grant's own user,
which Keymail allows.

## 3. Keymail delivery by federation

Keymail already has a way for a sender it does not know to reach any
mailbox: the federation protocol (`keymail/federation.go`). It is what one
Keymail instance uses to write to another, and it charges an unknown sender
a proof of work instead of requiring a grant. The console uses it as a
sender in its own name.

### 3.1 Identity

- **From** `console@<origin host>` (e.g. `console@console.platform.tito.io`),
  the same From `SendApp` already pins.
- **Signing key**: the console's existing long-lived envelope key
  (`Keymail.signKey`). Keymail registers a sender by (address, signing
  key); the same key on every send means the proof of work is paid once
  per recipient mailbox, and every later send to that mailbox is free.
- **Box key**: registration also records a key for replies to be sealed
  to. Nobody replies to an invite and the console serves no Keymail
  endpoints, so the console generates a P-256 ECDH key per process and
  never keeps the private half. A later re-registration with a different
  box key is harmless: Keymail keys free passage on the signing key.

### 3.2 Flow: `Keymail.SendFederated(ctx, to, subject, body) (msgID, error)`

All calls go to the configured Keymail base URL (`k.base`). On
keymail.dev that is the relay, which routes every federation verb to the
recipient's mailbox by address (`keymail/relay.go` `route`).

1. `GET /api/federation/lookup?addr=<to>&from=<from>` → `box_pub`,
   `pow_bits`. A non-200 here returns `ErrNoKeymailMailbox`, wrapping
   Keymail's message. The relay answers 400 "could not determine target
   mailbox" for an address it cannot resolve and 404 "no such mailbox"
   for an unknown local part.
2. Seal `{subject, body}` to `box_pub` and sign the envelope, exactly as
   `SendApp` does today. Factor the shared seal-and-sign into one helper.
3. `POST /api/federation/deliver {envelope}`. 200 means delivered.
4. On 403 `{"error":"unknown_sender","pow_bits":N}`:
   - If `N > maxFederationPowBits` (24), fail with
     `ErrFederationPriceTooHigh`. A mailbox can set its own price, and the
     console must not burn an HTTP request's worth of CPU on a hostile one.
   - `POST /api/federation/challenge {to, from, sign_pub}` →
     `{challenge, bits}`.
   - Mine: find `counter` with
     `SHA-256(SHA-256(challenge) || sign_pub_raw || counter_be64)` having
     ≥ `bits` leading zero bits (`keymail/internal/pow`). A port of
     `pow.Mine` of about 30 lines, cancelled by `ctx`. At 20 bits this is
     about a million hashes, well under a second.
   - `POST /api/federation/register {to, challenge, counter, from,
     sign_pub, box_pub, attestation}`, where attestation is the signing
     key's signature over `attestationMsg(from, sign_pub, box_pub)`.
   - Retry step 3 once.
5. Any other non-200 is an error carrying Keymail's text. A 403 "blocked"
   is an error like any other: the recipient has blocked the console.

The invite lands in the recipient's main inbox, not Apps. That is the
right place for a message from someone who is not an app the recipient
granted.

The protocol constants and message shapes (`attestationMsg`, the pow hash
layout, `envSigContext`) are copied from the Keymail source with a comment
naming the file, as `keymail.go` already does for the envelope.

## 4. The console email sender

### 4.1 Where the From address comes from

The identity is created through the platform's own mail layer, the same
`mail.EnsureDomain` walk apps use. It is owned by the built-in ops
account `bab` (`internal/release/qualified.go`), with the referrer app
name `console`. The send uses the console's own AWS role. No SMTP
credential exists, so nothing secret is stored.

Config, read in `cmd/console/main.go` next to the other `CARLOS_MAIL_*`
variables:

| Variable | Meaning |
|---|---|
| `CARLOS_CONSOLE_MAIL_FROM` | Full From address. Optional. |

- Set: use it. Its domain goes through `EnsureDomain` as whatever
  `DomainKindFor` says. A custom domain comes back with pending records
  that the operator publishes, exactly as for an account's custom domain.
- Unset, `CARLOS_MAIL_PLATFORM_SUFFIX` set: `noreply@console.bab.<suffix>`.
  This is a platform name owned by `bab`, since `platformNameOwnedBy`
  wants the sqid as the label left of the suffix. The DNS writer
  publishes its records automatically.
- Unset, no suffix, or `CARLOS_MAIL_REGION` unset (no provisioner): no
  email fallback. The console logs this once at startup and invites fall
  back to the hand-off link, as today.

### 4.2 Lifecycle

- **Startup**: a background goroutine runs `EnsureDomain(bab, "console",
  domain)`. It is idempotent: `EnsureIdentity` reads the identity back
  rather than minting new DKIM tokens. It logs pending records for a
  custom domain. A failure is logged and retried on the next send
  attempt, never fatal, matching how `mailBackends` degrades.
- **Send**: `ConsoleMailer.Send(ctx, to, subject, body)`.
  1. Check `IdentityStatus`. Anything but verified returns
     `ErrConsoleMailNotReady`. SES would refuse the send anyway; checking
     first gives a clear log line. Cache a verified result for the
     process lifetime and anything else for 5 minutes.
  2. SESv2 `SendEmail` with plain-text `Simple` content, `FromEmailAddress`
     = the From address, `TenantName = mail.TenantName("bab")`, and
     `ConfigurationSetName` = the domain record's config set when one
     exists, so bounces and complaints are counted like any sender's.
- **Seam**: `ConsoleMailer` is an interface with one `Send` method. The
  SES implementation takes a narrow SESv2 client interface, so tests use a
  fake.

### 4.3 Guarding the `console` referrer

The domain record names `console` as a referring app in `bab`. Any
teardown path that walks `bab`'s app domains must not delete this domain
if an app literally named `console` is deleted from `bab`. The plan's
first task checks `mail.Teardown` and the domain-detach paths and either
reserves the name `console` in `bab` for apps, or records the referrer
under a name `release.ValidApp` accepts but app creation refuses. The
plan picks one after reading the code; the spec only requires that no
user action on `bab` can tear down the console's identity.

### 4.4 IAM (`infra/modules/region-host`)

New optional variable `console_mail_identity_arns` (list, default empty).
When non-empty, the host role gets:

```
ses:SendEmail
  Resource: those identity ARNs (+ the config set ARN pattern for the deployment)
  Condition: StringLike ses:FromAddress = the configured From address
```

The live deployments' existing SES/IAM rights for the provisioner were
granted outside this repo (nothing in `infra/modules` mentions `ses:`).
The plan records where each deployment's grants live before adding to
them. Flagship is `keymail` (271376211898). Tito is `titogo`
(665106592142), and a tofu apply there needs Paul's go. That apply can
ride with the store-boundary IAM that already blocks Tito's console
upgrade.

## 5. `handleInvite`, rewritten

```
tok := CreateInvite(...)                       // unchanged
link := origin + "/invite/" + tok

ctx 20s
err := km.SendFederated(ctx, to, subj, body)   // Keymail first
if err == nil → flash ok  "…in their Keymail inbox"
log err
if mailer != nil:
    err2 := mailer.Send(ctx, to, subj, body)   // email second
    if err2 == nil → flash ok "…emailed to <to>"
    log err2
flash err "…delivering the link failed… <link>"  // unchanged fallback
```

- Email is tried after **any** Keymail failure, not only "no mailbox".
  The invite link is bound to the invitee's signed-in identity (accepting
  it requires being signed in as `to`), so plain email does not weaken
  it. Delivery matters more here than sealing.
- The inviter's grant is no longer consulted, which also fixes the false
  success for an inviter with no grant.
- The `ValidAddress` gate on the invite form stays as it is. Widening
  who can be invited belongs to the sign-in spec.
- Worst case, the request waits for a Keymail timeout followed by an SES
  call. The shared 20s context bounds it.

## 6. Copy (goes through copy review before it is written)

Drafts only. The copy-review skill runs over these before any lands in a
file:

1. Flash, Keymail delivered: "Invited {to}. The link is in their Keymail
   inbox."
2. Flash, emailed: "Invited {to}. We emailed them the link."
3. Flash, both failed: unchanged ("Invited {to}, but delivering the link
   failed — you can send it on yourself: {link}"). It has an em dash, so
   review it too.
4. Message subject: unchanged ("You've been invited to an account on the
   CARLOS platform").
5. Message body: unchanged text, reviewed as one string because email now
   carries it.

## 7. Testing

- **Keymail fake** (`keymail_test.go`): `/api/app/send` enforces
  `to == grant user` and answers 403 with Keymail's message otherwise.
  This is the regression test for §1. It also gains lookup (resolvable
  and unresolvable addresses), challenge, register and deliver, with a
  real pow check at 8 bits, a sender registry keyed by (from, sign_pub),
  and a configurable price.
- **`SendFederated`**:
  - a known sender delivers without pow;
  - an unknown sender mines, registers and delivers;
  - a price above the cap is refused without mining;
  - a blocked sender gets an error;
  - an unresolvable address gets `ErrNoKeymailMailbox`;
  - the delivered envelope verifies and unseals.
- **`ConsoleMailer`** with a fake SESv2 client:
  - unverified → `ErrConsoleMailNotReady` and no send;
  - verified → one `SendEmail` with tenant, config set and From set;
  - the verified cache holds.
- **`handleInvite`**, table-driven over:
  - Keymail ok;
  - no mailbox + email ok;
  - Keymail error + email ok;
  - both fail;
  - no mailer + Keymail fails;
  - inviter with no grant still delivers.

  Each case asserts the flash kind and text, and that the invite row
  exists either way.
- **Config**: the From resolution table in §4.1.
- `GOOS=darwin` and `GOOS=windows` builds stay green (cross-compile gate).

## 8. Rollout

1. Merge, then roll consoles by the normal recipe. Keymail federation
   works immediately with no infra change, so the §1 bug is fixed as soon
   as a console runs the build.
2. Flagship: apply the region-host variable, restart the console, check
   the identity verifies, and invite a non-Keymail address.
3. Tito: blocked on the store-boundary IAM (Tito's console is pinned on
   cf02a4d5). Bundle the SES grant with that apply, with Paul's go.
