carlos / platform Public
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.devhandleInvite (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 FromSendAppalready 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).
GET /api/federation/lookup?addr=<to>&from=<from>→box_pub,pow_bits. A non-200 here returnsErrNoKeymailMailbox, 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.- Seal
{subject, body}tobox_puband sign the envelope, exactly asSendAppdoes today. Factor the shared seal-and-sign into one helper. POST /api/federation/deliver {envelope}. 200 means delivered.- On 403
{"error":"unknown_sender","pow_bits":N}: - IfN > maxFederationPowBits(24), fail withErrFederationPriceTooHigh. 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: findcounterwithSHA-256(SHA-256(challenge) || sign_pub_raw || counter_be64)having ≥bitsleading zero bits (keymail/internal/pow). A port ofpow.Mineof about 30 lines, cancelled byctx. 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 overattestationMsg(from, sign_pub, box_pub). - Retry step 3 once. - 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
EnsureDomainas whateverDomainKindForsays. A custom domain comes back with pending records that the operator publishes, exactly as for an account's custom domain. - Unset,
CARLOS_MAIL_PLATFORM_SUFFIXset:noreply@console.bab.<suffix>. This is a platform name owned bybab, sinceplatformNameOwnedBywants the sqid as the label left of the suffix. The DNS writer publishes its records automatically. - Unset, no suffix, or
CARLOS_MAIL_REGIONunset (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:EnsureIdentityreads 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 howmailBackendsdegrades. - Send:
ConsoleMailer.Send(ctx, to, subject, body). 1. CheckIdentityStatus. Anything but verified returnsErrConsoleMailNotReady. 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. SESv2SendEmailwith plain-textSimplecontent,FromEmailAddress= the From address,TenantName = mail.TenantName("bab"), andConfigurationSetName= the domain record's config set when one exists, so bounces and complaints are counted like any sender's. - Seam:
ConsoleMaileris an interface with oneSendmethod. 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 addressThe 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
ValidAddressgate 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:
- Flash, Keymail delivered: "Invited {to}. The link is in their Keymail inbox."
- Flash, emailed: "Invited {to}. We emailed them the link."
- 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.
- Message subject: unchanged ("You've been invited to an account on the CARLOS platform").
- Message body: unchanged text, reviewed as one string because email now carries it.
7. Testing
- Keymail fake (
keymail_test.go):/api/app/sendenforcesto == grant userand 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 getsErrNoKeymailMailbox; - the delivered envelope verifies and unseals.ConsoleMailerwith a fake SESv2 client: - unverified →ErrConsoleMailNotReadyand no send; - verified → oneSendEmailwith 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=darwinandGOOS=windowsbuilds stay green (cross-compile gate).
8. Rollout
- 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.
- Flagship: apply the region-host variable, restart the console, check the identity verifies, and invite a non-Keymail address.
- 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.