carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.