carlos / platform Public

Merge remote-tracking branch 'origin/main' into claim-untagged-pointer

# Conflicts:
#	internal/provision/plan14_root_reads_test.go
claude agent, operated by paul@keymail.dev a38e4242a1bdfa109f80be4b9a2b031fa84af2b9 parent b247ae3parent 91a651b
30 files changed, +4748 −68
  • cmd/console/mailbackends_test.go +50 −0
  • cmd/console/main.go +57 −2
  • docs/superpowers/notes/census-proposal-console-invite-delivery.md +318 −0
  • docs/superpowers/plans/2026-10-02-console-invite-delivery.md +1838 −0
  • docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md +267 −0
  • infra/modules/region-host/main.tf +30 −0
  • infra/modules/region-host/tests/console_mail.tftest.hcl +105 −0
  • infra/modules/region-host/variables.tf +16 −0
  • internal/console/appsettings.go +6 −4
  • internal/console/consolemail.go +177 −0
  • internal/console/consolemail_test.go +278 −0
  • internal/console/invite_delivery_test.go +373 −0
  • internal/console/keymail.go +44 −21
  • internal/console/keymail_test.go +195 −7
  • internal/console/keymailfed.go +186 −0
  • internal/console/keymailfed_test.go +171 −0
  • internal/console/keymailpow.go +71 −0
  • internal/console/keymailpow_test.go +73 −0
  • internal/console/server.go +175 −31
  • internal/console/server_account_test.go +2 −2
  • internal/console/store.go +9 −0
  • internal/console/testdata/claim-authority-sites.txt +1 −0
  • internal/mail/consolemail.go +25 −0
  • internal/mail/consolemail_test.go +118 −0
  • internal/mail/sessend.go +45 −0
  • internal/mail/walk.go +22 −0
  • internal/provision/plan14_root_reads_test.go +13 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/root_binary_deps_test.go +56 −0
  • internal/provision/testdata/plan14-root-reads-console-invite-delivery.tsv +26 −0

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	315.126s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.905s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.220s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.699s
ok  	github.com/carlosframework/platform/internal/activator	250.671s
ok  	github.com/carlosframework/platform/internal/agentsock	0.017s
ok  	github.com/carlosframework/platform/internal/alerting	0.004s
ok  	github.com/carlosframework/platform/internal/analytics	0.018s
ok  	github.com/carlosframework/platform/internal/appconfig	2.465s
ok  	github.com/carlosframework/platform/internal/apperr	0.072s
ok  	github.com/carlosframework/platform/internal/applog	0.121s
ok  	github.com/carlosframework/platform/internal/awsinv	0.003s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.737s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.694s
ok  	github.com/carlosframework/platform/internal/changelog	0.313s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.776s
ok  	github.com/carlosframework/platform/internal/clamd	0.055s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	406.520s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.093s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.005s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.535s
ok  	github.com/carlosframework/platform/internal/doorkey	0.009s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	45.009s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.145s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.014s
ok  	github.com/carlosframework/platform/internal/geo	0.009s
ok  	github.com/carlosframework/platform/internal/geoip	0.032s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.192s
ok  	github.com/carlosframework/platform/internal/host	181.215s
ok  	github.com/carlosframework/platform/internal/hostactors	6.209s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.022s
ok  	github.com/carlosframework/platform/internal/hostbox	2.629s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.575s
ok  	github.com/carlosframework/platform/internal/hostenv	0.022s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.022s
ok  	github.com/carlosframework/platform/internal/hostexec	4.820s
ok  	github.com/carlosframework/platform/internal/hostfence	0.043s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.438s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.022s
ok  	github.com/carlosframework/platform/internal/hostguard	1.003s
ok  	github.com/carlosframework/platform/internal/hostops	1.600s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.022s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.022s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.477s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.141s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.023s
ok  	github.com/carlosframework/platform/internal/infraview	0.022s
ok  	github.com/carlosframework/platform/internal/instancectl	5.236s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.738s
ok  	github.com/carlosframework/platform/internal/instancefs	0.022s
ok  	github.com/carlosframework/platform/internal/instancekey	0.017s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.022s
ok  	github.com/carlosframework/platform/internal/layout	1.550s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.002s
ok  	github.com/carlosframework/platform/internal/lease	9.793s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.007s
ok  	github.com/carlosframework/platform/internal/ledger	0.012s
ok  	github.com/carlosframework/platform/internal/mail	0.165s
ok  	github.com/carlosframework/platform/internal/maintenance	0.089s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.532s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.078s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	21.005s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.789s
ok  	github.com/carlosframework/platform/internal/objstore	0.185s
ok  	github.com/carlosframework/platform/internal/ops	0.174s
ok  	github.com/carlosframework/platform/internal/park	0.200s
ok  	github.com/carlosframework/platform/internal/privatefile	0.008s
ok  	github.com/carlosframework/platform/internal/provision	25.219s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.460s
ok  	github.com/carlosframework/platform/internal/relay	7.230s
ok  	github.com/carlosframework/platform/internal/release	24.115s
ok  	github.com/carlosframework/platform/internal/rollout	3.642s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.057s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.580s
ok  	github.com/carlosframework/platform/internal/s3compat	0.006s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.508s
ok  	github.com/carlosframework/platform/internal/seeds	0.011s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.227s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.610s
ok  	github.com/carlosframework/platform/internal/status	0.057s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.202s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.007s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.565s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49414306 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/18 20:10:21 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DFB8AB06B1F772, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.021s
minio contract: PASS
diff --git a/cmd/console/mailbackends_test.go b/cmd/console/mailbackends_test.go
index dd890792a..2ffd0ed32 100644
--- a/cmd/console/mailbackends_test.go
+++ b/cmd/console/mailbackends_test.go
@@ -5,8 +5,11 @@ import (
"errors"
"testing"
+ "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"
)
// failIfCalled builds a newProvisioner/newWriter seam that fails the test if
@@ -126,3 +129,50 @@ func TestMailBackendsBothConfiguredBothConstructed(t *testing.T) {
t.Errorf("writer = %v, want the constructed MemWriter", writer)
}
}
+
+// TestConsoleMailerWiring covers consoleMailer's own coordinates (From,
+// Provisioner, SES client construction) — distinct from mailBackends' above,
+// since the console mailer additionally needs a From address and a working
+// SES client, neither of which mailBackends' Provisioner/Writer pair cover.
+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, false); 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, false); m != nil {
+ t.Error("no suffix, no explicit From: want no mailer")
+ }
+ if m, _ := consoleMailer(dep, store, nil, nil, sesOK, false); m != nil {
+ t.Error("no provisioner: want no mailer")
+ }
+ t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "invites@shop.example")
+ if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK, false); 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, false); m != nil {
+ t.Error("SES client construction failed: want no mailer, not a crash")
+ }
+ // #250: a console under claims must not have console mail until a gate
+ // route exists — hostClaim true refuses even when every other coordinate
+ // (suffix, region, provisioner) is set, and must do so before touching
+ // SES at all.
+ sesMustNotBeCalled := func(context.Context, string) (mail.EmailSender, error) {
+ t.Fatal("newSES called — consoleMailer must not construct a SES client when hostClaim is true")
+ return nil, nil
+ }
+ if m, _ := consoleMailer(dep, store, prov, nil, sesMustNotBeCalled, true); m != nil {
+ t.Error("hostClaim true: want no mailer, even with suffix, region and provisioner set")
+ }
+}
+
+type fakeSESClient struct{}
+
+func (fakeSESClient) SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) {
+ return &sesv2.SendEmailOutput{}, nil
+}
diff --git a/cmd/console/main.go b/cmd/console/main.go
index a2cb0daa7..192e023d4 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -68,6 +68,19 @@
// field nil rather than stopping the console from booting — see
// mailBackends' doc comment.
//
+// The console's own email (the invite fallback when Keymail cannot deliver)
+// sends From CARLOS_CONSOLE_MAIL_FROM, defaulting to
+// noreply@console-mail.bab.<CARLOS_MAIL_PLATFORM_SUFFIX> when the suffix is set.
+// It needs CARLOS_MAIL_REGION (a mail Provisioner) and the host role's
+// ses:SendEmail grant from infra/modules/region-host's console_mail_from and
+// console_mail_region variables, which must equal the resolved From and
+// CARLOS_MAIL_REGION. With neither a From nor a suffix there is no console
+// email, and invites go by Keymail or by hand. If the domain is already an
+// SES identity the console did not create, mail.EnsureDomain refuses it
+// until a _carlos-mail.<domain> TXT proof is published (#216). There is no
+// console email while CARLOS_HOST_CLAIM is on, because the console mailer
+// does not yet go through the claim mail gate (#250).
+//
// grant/revoke/owners need only CONSOLE_DB.
package main
@@ -275,7 +288,8 @@ func serve() {
} else {
log.Print("console: CARLOS_RELEASE_KEY unset — promotions disabled, running read-only")
}
- st := openServeDB(hostClaimEnabled(os.Getenv))
+ hostClaim := hostClaimEnabled(os.Getenv)
+ st := openServeDB(hostClaim)
storeProvisioner, storeDeployment := storeBackend()
storeScanner, seedAntivirus := storeScannerFor(storeDeployment)
mailDep := mailDeployment()
@@ -283,8 +297,8 @@ func serve() {
// One release.Store, named, because the senders sweep below needs the
// same one the server got.
rel := openStore()
+ consoleMail, ensureConsoleMail := consoleMailer(mailDep, rel, mailProvisioner, dnsWriter, mail.NewSESEmailSender, hostClaim)
dbBucket, dbRegion, dbPrefix, placement := dbDoorWiring()
- hostClaim := hostClaimEnabled(os.Getenv)
var dbVersions lease.VersionedStore
if hostClaim {
buckets := map[string]string{}
@@ -406,6 +420,11 @@ func serve() {
MailOperators: mailOperators(),
MailProvisioner: mailProvisioner,
DNSWriter: dnsWriter,
+ // ConsoleMail is the console's own email sender, the invite fallback
+ // when Keymail cannot deliver (consoleMailer above). nil on every
+ // deployment with no From address or no mail Provisioner — invites
+ // then fall through to the hand-off link.
+ ConsoleMail: consoleMail,
// The STUN/TURN relay (relay spec §5). The region list is the whole
// switch: unset is every deployment until an operator arms one, and
// it leaves the relay doors refusing and no background loop running.
@@ -427,6 +446,18 @@ func serve() {
if err != nil {
log.Fatal(err)
}
+ // The console mailer's identity ensure (consoleMailer above) — makes or
+ // re-reads the sending domain's SES/DKIM identity. Started here, not in
+ // NewServer, same reasoning as the alert watcher below: constructing a
+ // Server never starts a goroutine or sends mail. Bounded: the AWS SDK has
+ // no overall timeout, and an ensure that never returns would hold the
+ // mailer for good (a send gives up at its own deadline, but every one
+ // would).
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ ensureConsoleMail(ctx)
+ }()
// The senders migration's tail (tenant-email senders design §5): once a
// release has passed with every console writing senders/app.json, an
// operator sets CARLOS_MAIL_LEGACY_SWEEP=1 and the next start deletes the
@@ -958,6 +989,30 @@ func mailBackends(
return provisioner, writer
}
+// 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), hostClaim bool) (console.ConsoleMailer, func(context.Context)) {
+ if hostClaim {
+ log.Print("console: no console email while CARLOS_HOST_CLAIM is on (the console mailer does not go through the claim mail gate yet; #250); invites go by Keymail or by hand")
+ return nil, 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)
+}
+
// edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the
// agent's domains reconcile loop reads, so the console's point-your-DNS-here
// guidance and the agent's DNS health checks can never name different
diff --git a/docs/superpowers/notes/census-proposal-console-invite-delivery.md b/docs/superpowers/notes/census-proposal-console-invite-delivery.md
new file mode 100644
index 000000000..6ddc8a81c
--- /dev/null
+++ b/docs/superpowers/notes/census-proposal-console-invite-delivery.md
@@ -0,0 +1,318 @@
+# Census: console invite delivery
+
+Status: RULED as census-ruling-console-invite-delivery (platform-1e, R1–R5),
+on 5aef01db. The ruling is
+`.superpowers/sdd/2026-10-02-console-invite-delivery/census-ruling.md`.
+The overlay is loaded last in `TestPlan14RootReadCensus`, its rows carry
+`rev3=census-ruling-console-invite-delivery`, and the seal is moved to
+`fcdf9de0…d7a3`. platform-1e rules as the Plan 14 census owner. The
+proposal below is kept as it was put, with the PROPOSED wording brought up
+to date where the ruling settled it.
+
+## The ruling
+
+- **R1. Accepted.** The seven units (1 to 7 below) are accepted legacy under
+ the identities named: `serve`, `NewServer`, `Keymail.do`,
+ `Keymail.sealEnvelope` and `Store.CountPendingInvites` run in the console
+ (cmd/console serve under console.service, user carlos-console); the two
+ region-host files are run by tofu on an operator's machine. The basis is
+ that the root `carlos` binary links neither `internal/console` nor
+ `internal/mail`. No check pinned that before this ruling, so one is added:
+ `TestRootBinaryLinksNoConsoleOrMail`
+ (`internal/provision/root_binary_deps_test.go:18`) runs
+ `go list -deps .` for linux, darwin and windows and fails if either
+ package, or anything under it, appears. It skips when `go` is not on
+ PATH. This ruling is the separate scope ruling the legacy freeze needs,
+ so the seal moves.
+- **R2. Accepted.** `Keymail.SendApp`'s read row is retired by an H3
+ census-ruling removal, in the form census ruling carlos-run used for
+ `apiSession.getJSON` (`plan14-root-reads-carlos-run.tsv:126-127`). Its
+ one read, `crypto/rand.Read` for the envelope id, moved unchanged into
+ `sealEnvelope`, the same class.
+- **R3. Out of census scope.** The `ses:SendEmail` grant goes on the shared
+ host role, which every process holding the box's instance credentials can
+ use. Merging is fine, because the policy has count 0 by default. It needs
+ Paul's own security ruling before anyone sets `console_mail_from`. The
+ rollout gate, verbatim: "Paul approves the host-role SES grant (or a
+ console-only role) before console_mail_from is set". Whether tenant code
+ can reach the instance credentials is answered under "R3: who can reach
+ the instance credentials" below.
+- **R4. Rewritten.** The `PROPOSED-console-invite-delivery` marker is now
+ `census-ruling-console-invite-delivery` in every row, as box-publish-record
+ and the step 6 overlays did, and the overlay header names the ruling. The
+ `@legacy` fingerprints were recomputed for the new basis text. The seal,
+ recomputed with the overlay loaded after box-publish-record, is
+ `fcdf9de0ba39db40d6bc7d1d2137d4f40d8248df8d286a380b2eae4e8b31d7a3`, the
+ value the drafter computed for this form.
+- **R5. Not a merge blocker** (HostClaim is off, and ruling 6 holds). It is a
+ claim-activation blocker for any console that sends mail, filed on #250.
+ With `CARLOS_HOST_CLAIM` on, the console mailer's `EnsureDomain` and
+ `VerifyDomain` must go through the claim mail gate: either console-mail is
+ registered as a platform identity the admitter knows, or the mailer refuses
+ to start when HostClaim is on. Until then a console under claims must not
+ have console mail enabled.
+- Re-digest after 0da9b965 (HostClaim guard passes hostClaim into
+ consoleMailer; no new read site): serve and cmd/console envelope
+ re-digested, seal fcdf9de0 → 53b3cbb6, awaiting platform-1e's
+ confirmation.
+
+## R3: who can reach the instance credentials
+
+The question is whether tenant code on a box can reach the EC2 instance
+metadata service (IMDS, 169.254.169.254) and so the host role's
+credentials. If it can, it can send as `console_mail_from` once the grant is
+on.
+
+What holds today, from this repo:
+
+- **Exec-backed instances in the flat layout can reach IMDS.** The flat
+ layout is the default: `ExecBackend.Start` runs every row that is not
+ nested, fenced or in conflict as an exec child of the agent
+ (`internal/activator/backend_exec.go:1829-1837`; flat is "today's exec
+ child", `internal/activator/layout.go:15-17`). Those children run as
+ uid carlos inside `carlos-edge.service`'s delegated cgroup
+ (`internal/host/units/carlos-edge.service:28` `User=carlos`, `:64` "The
+ agent and its flat children", `:95-101` `Delegate=`). That unit has no
+ `IPAddressDeny=` line, because the agent itself reads IMDS
+ (`internal/host/agent.go:2964`, `internal/host/memwatch.go:186-189`). The
+ only other fence is the nftables skuid gate, and it lets uid carlos
+ through by construction (`memwatch.go:189`).
+- **Exec-backed instances in the nested layout (#126) cannot.** They are
+ still uid carlos, not a separate uid
+ (`internal/host/units/carlos-instance@.service:4` and `:28`), but each
+ template carries a unit-level `IPAddressDeny=` that covers
+ 169.254.169.254/32 and fd00:ec2::254/128
+ (`carlos-instance@.service:142`, `carlos-instance-sidecar@.service:142`,
+ `carlos-instance-cmd@.service:133`, `carlos-instance-gen@.service:115`,
+ `carlos-instance-seed@.service:143`). systemd applies it as a cgroup BPF
+ firewall, so it holds whatever the nftables rules say.
+- **Unit-backed instances (DynamicUser) cannot, unless they hold
+ `host_role`.** `carlos-app@.service:40` is `DynamicUser=yes` and `:69` is
+ `IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128`. On a hardened box
+ the skuid gate also refuses dynamic uids (`carlos-app@.service:9-15`). A
+ tenant granted `host_role` gets a drop-in with
+ `IPAddressAllow=169.254.169.254/32 fd00:ec2::254/128`, which wins over
+ the deny (`internal/caps/render.go:252-259`), and then only the skuid gate
+ stands between it and the role.
+
+What depends on settings this repo does not hold:
+
+- **The skuid gate.** It is written by the infra repo's
+ `scripts/harden-host.sh`, not by anything here
+ (`internal/host/adopt_advice.go:204-207`). A box without it has no
+ nftables fence at all (`adopt_advice.go:156`; tito, issue #351, per
+ `carlos-app@.service:10-11`). On such a box a `host_role` unit tenant
+ reaches IMDS too.
+- **IMDS options.** `aws_instance.host`
+ (`infra/modules/region-host/main.tf:482-511`) sets no `metadata_options`,
+ so `http_tokens` and `http_put_response_hop_limit` take the AMI's and the
+ account's defaults (the scanner host sets `http_tokens = "required"` and a
+ hop limit of 1, `infra/modules/scanner-host/main.tf:126-129`; the region
+ host does not). Neither setting would fence these tenants anyway. They
+ share the host's network namespace, so a hop limit of 1 reaches them, and
+ any local process can do the IMDSv2 token PUT.
+- **Which layout a box's rows are in.** A row is nested only after plan 4's
+ migration moves it. Until then, on any box that runs flat exec-backed
+ tenants, including the console's box if it hosts them, tenant code can
+ fetch the host role's credentials and, with the grant on, send as
+ `console_mail_from`. A console-only role, or a skuid gate that separates
+ the console's uid from carlos, would close that.
+
+Branch `design-console-invite-delivery`, head 05cfbec0, with origin/main
+merged in at c80e2f3b. Design:
+`docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md`.
+
+## What changed
+
+An owner's invite goes out by Keymail federation first (into the invitee's
+main inbox, sent as the console, no grant needed). If Keymail can't deliver,
+it goes by the console's own email. If that fails too, the owner gets the link
+to pass on. Two limits refuse an invite before its row exists: 20 waiting per
+account, and 20 an hour.
+
+What the root-read census sees:
+
+- `internal/console/keymail.go`: `Keymail.do` returns a typed
+ `keymailError` for a non-200 answer. SendApp's envelope sealing moves,
+ unchanged, into a new `Keymail.sealEnvelope`, which `SendApp` and the new
+ `SendFederated` (keymailfed.go) share.
+- `internal/console/server.go`: `NewServer` sets the invite delivery fields.
+ `handleInvite` is rewritten, but it has no read site of its own.
+- `internal/console/store.go`: new `Store.CountPendingInvites`.
+- `cmd/console/main.go`: `serve()` builds the console mailer
+ (`consoleMailer`) and starts its identity ensure in a goroutine.
+- `infra/modules/region-host`: an optional `ses:SendEmail` grant on the host
+ role (`main.tf`), and the two variables that switch it on (`variables.tf`).
+- New files with no read site: `internal/console/consolemail.go`,
+ `keymailfed.go`, `keymailpow.go`, `internal/mail/consolemail.go`,
+ `internal/mail/sessend.go`. `internal/mail/walk.go` gains the console-mail
+ teardown guards.
+
+None of this runs as root. The root `carlos` binary links neither
+`internal/console` nor `internal/mail`: `go list -deps .` names neither, and
+the only importers of `internal/mail` are `cmd/console` and
+`internal/console`. `TestRootBinaryLinksNoConsoleOrMail` pins that (R1). The `.tf` files are run by tofu on an operator's machine.
+No binary embeds them and no box reads them.
+
+## Root-read census (TestPlan14RootReadCensus)
+
+Overlay `internal/provision/testdata/plan14-root-reads-console-invite-delivery.tsv`,
+to load last, after `plan14-root-reads-box-publish-record.tsv`.
+
+After the merge the test reports 18 discrepancies. With the overlay loaded,
+17 clear and one is left, "changed legacy freeze; separate scope ruling
+required", which is the seal. With the ruled seal set as well, every
+`TestPlan14*` test passes.
+
+Every row is accepted-legacy and tagged `rev3=census-ruling-console-invite-delivery` (R4).
+Each basis names the executing identity, in the form census rulings carlos-run
+and seeded-instances use for console rows ("the console (cmd/console serve
+under console.service, user carlos-console): ...").
+
+### Changed units, re-frozen with their read sites unchanged
+
+Each of these was a "changed root read" and a "changed legacy declaration".
+
+1. **`cmd/console/main.go:serve`**: data-literal@3446,4547,4784, digest
+ `a9ecdd3b…9825`. Main froze it under seeded-instances at
+ @3331,4432,4669. The same three literals moved by 115 bytes, which is the
+ one inserted line at main.go:297 (`consoleMail, ensureConsoleMail :=
+ consoleMailer(...)`). The literals are the `CARLOS_REGISTRY` default
+ `/data/registry.db` (main.go:333) and the `CONSOLE_DB` default
+ `/data/console/console.db`, used twice (main.go:352 and 355). The other
+ changes are `ConsoleMail: consoleMail` in Config, and a goroutine bounded at
+ two minutes that runs `ensureConsoleMail` (main.go:457). The mailer is built
+ over the release store, provisioner and DNS writer that serve already holds.
+ `consoleMailer` itself (main.go:995) has no read site.
+ **Proposed:** accepted-legacy, the console.
+
+2. **`internal/console/server.go:NewServer`**: data-literal@9108, digest
+ `4d82a686…b032`. Main had it at @8926. The literal is the route pattern
+ `"GET /a/{sqid}/apps/{app}/logs/data"` (server.go:1416), not a path. The
+ change sets `consoleMail`, `inviteTimeout` and `inviteKeymailShare`
+ (server.go:1309), and binds `sendFederated` beside `sendApp`
+ (server.go:1329). No goroutine is started. The record-write census
+ re-digested the same unit in 05cfbec0.
+ **Proposed:** accepted-legacy, the console.
+
+3. **`internal/console/keymail.go:Keymail.do`**: file:ReadAll@594, which is
+ the same site as on main. Only the digest moved, to `e2f193e4…e7be`. The
+ read is `io.ReadAll(io.LimitReader(res.Body, 1<<20))` (keymail.go:344),
+ which reads Keymail's HTTP response body. The change is the error value
+ (keymail.go:356). Its Error() text is the same as before, and it adds
+ Status and Msg so that `IsKeymailBlocked` can classify the answer.
+ **Proposed:** accepted-legacy, the console.
+
+4. **`infra/modules/region-host/main.tf:external`**: digest
+ `6ae47ec3…9c5b`. Adds `aws_iam_role_policy.host_console_mail`
+ (main.tf:702). It has count 0 unless `console_mail_from` is set. It grants
+ `ses:SendEmail` on the one sending identity, the `carlos-bab` configuration
+ set and its tenants, on condition that `ses:FromAddress` equals
+ `console_mail_from` (main.tf:717).
+ **Proposed:** accepted-legacy, tofu on an operator's machine. No read is
+ added. See question 3: this is an authority grant on the box's role, not a
+ read.
+
+5. **`infra/modules/region-host/variables.tf:external`**: digest
+ `ffdad585…250e`. Adds `console_mail_from` (variables.tf:221, validated as
+ one lowercase address) and `console_mail_region` (variables.tf:231). Both
+ are empty by default.
+ **Proposed:** accepted-legacy, tofu on an operator's machine.
+
+### New units
+
+6. **`internal/console/keymail.go:Keymail.sealEnvelope`**:
+ "unclassified root read … file:Read@387", digest `b140fde1…7658`.
+ `file:Read@387` is `rand.Read(idb)` at keymail.go:258, and `rand` is
+ `crypto/rand` (keymail.go:21). It reads the 16-byte envelope id from the
+ kernel's random source. It reads no file, store or box state. It is the
+ site main froze in `SendApp` as file:Read@949 (accepted-legacy, J4
+ root-input), moved over unchanged with the rest of the sealing.
+ **Proposed:** accepted-legacy, the console, the same class as the site it
+ came from.
+
+7. **`internal/console/store.go:Store.CountPendingInvites`**:
+ "unclassified root read … file:QueryRow@102", digest `1733078d…18e5`. One
+ SQLite `QueryRow` (store.go:1400): `SELECT COUNT(*) FROM invites WHERE
+ account_id = ? AND accepted_at = 0 AND expires_at >= ?`. It runs on the
+ console's own database (`CONSOLE_DB`). Its one caller is handleInvite's
+ pending cap (server.go:2446), and it can only refuse an invite. Its
+ siblings `Store.PendingInvites`, `CreateInvite` and `RevokeInvite` are
+ accepted-legacy (J4 root-input) on main, and so are all 84 `store.go`
+ rows.
+ **Proposed:** accepted-legacy, the console.
+
+### Retired unit
+
+8. **`internal/console/keymail.go:Keymail.SendApp`**: "stale root read" and
+ "changed legacy declaration". It is still declared (keymail.go:279) and
+ reads nothing now. Its one read moved into `sealEnvelope`, which it calls
+ at keymail.go:293. A frozen legacy row cannot have empty kinds (the
+ compare reports it stale), so it has to be retired, not re-frozen.
+ **Proposed:** `@remove` and `@removed … H3`, the form census ruling
+ carlos-run used for `apiSession.getJSON` and `ensureExe`. The loader
+ accepts an H3 removal only with a `census-ruling-` basis, so the row reads
+ `census-ruling-console-invite-delivery: …` (R2). A removal is not part of
+ the seal.
+
+### Source envelopes
+
+9. The four "changed root source envelope" rows are re-pinned:
+ - `cmd/console` to `2af70021…c0c6`
+ - `infra/modules/region-host` to `82e90b10…56e7`
+ - `internal/console` to `18fc22e0…5756`
+ - `internal/mail` to `c45dd340…e317`
+
+ The scan finds no read site in any new file. In the changed files, the only
+ read-site changes are units 1 to 8. One thing the scanner does not see:
+ `mail.NewSESEmailSender` (sessend.go:22) calls
+ `awsconfig.LoadDefaultConfig`. That is the console's own credential
+ chain, which on a box is the host role. It is already listed in
+ `TestClaimAuthoritySourceCensus` as a send-only SES client (05cfbec0).
+
+### Seal
+
+The overlay adds seven `@legacy` rows, which takes the frozen set from 2147
+to 2154. Computed with the overlay loaded after box-publish-record:
+
+- As drafted (the marker kept on ruling, as census ruling seed did):
+ `5ebadd0439ec247784a13739c74ab7363e22a3a9fb8da3d7ef03a76f1ab75468`
+- With the tag rewritten to `rev3=census-ruling-console-invite-delivery`,
+ as R4 ruled, and the seal the test now uses:
+ **`fcdf9de0ba39db40d6bc7d1d2137d4f40d8248df8d286a380b2eae4e8b31d7a3`**
+
+The test edit as proposed is below. As applied, it sits after the
+box-publish-record load in `TestPlan14RootReadCensus` with R4's seal:
+
+```go
+ // Census overlay console-invite-delivery (console invite delivery),
+ // loaded last, under census-ruling-console-invite-delivery (platform-1e):
+ // ... the ruling's lines ...
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-console-invite-delivery.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ seal = "5ebadd0439ec247784a13739c74ab7363e22a3a9fb8da3d7ef03a76f1ab75468"
+```
+
+## Questions for platform-1e
+
+1. Do you accept the seven units (1 to 7) as accepted legacy, under the
+ executing identities named (the console as carlos-console, and tofu on an
+ operator's machine), with seal `5ebadd04…5468`?
+2. Should `Keymail.SendApp` be retired by an H3 census-ruling removal (the
+ carlos-run getJSON form), now that its only read lives in `sealEnvelope`?
+3. The `ses:SendEmail` grant is on the shared host role. Any process that
+ holds the box's role credentials (root, the agent or the console) can send
+ as `console_mail_from`, and only as that address. That is a grant, not a
+ read. Is it inside this census's scope, or does it need its own ruling
+ elsewhere?
+4. Keep the `PROPOSED-console-invite-delivery` marker in the frozen bases
+ (seal `5ebadd04…`), or rewrite it to a `census-ruling-` tag (seal
+ `fcdf9de0…`)?
+5. This is not a census item, but you own host claims, so it is flagged for
+ you. The console mailer is built over the raw release store, not
+ `claimMailStore()`. Its `EnsureDomain` and `VerifyDomain` on
+ `ops/console-mail` would skip the claim mail gate when HostClaim is on.
+ Switching to the gated store would fail today, because `console-mail` is
+ not a registered app. HostClaim is off by default, so nothing changes now.
+ Should this block the merge, or wait for a HostClaim design decision?
diff --git a/docs/superpowers/plans/2026-10-02-console-invite-delivery.md b/docs/superpowers/plans/2026-10-02-console-invite-delivery.md
new file mode 100644
index 000000000..80deb3c24
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-02-console-invite-delivery.md
@@ -0,0 +1,1838 @@
+# 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-delivery` on branch
+ `design-console-invite-delivery`; never `git checkout -- <file>` and never
+ bare `git stash`.
+- Go builds from this sandbox need `export GOCACHE=$TMPDIR/gocache` in the
+ same command; socket tests need `TMPDIR=/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 hash `SHA-256(nonce || sign_pub_raw ||
+ counter_be64)` with `nonce = SHA-256(challenge_token)`
+ (`keymail/internal/pow`, `keymail/federation.go` handleRegister).
+- `maxFederationPowBits = 24`.
+- Federation From address: `console@` + `OriginHost(origin)`, the same From
+ `SendApp` uses.
+- The console's mail identity lives under account `release.OpsAccount`
+ (`"bab"`) with referrer app name `console-mail`, which `claimApp` refuses
+ in `bab`.
+- From resolution: `CARLOS_CONSOLE_MAIL_FROM` if set; else
+ `noreply@console.bab.<CARLOS_MAIL_PLATFORM_SUFFIX>` if the suffix is set;
+ else no email fallback. No email fallback either when
+ `cfg.MailProvisioner` is nil.
+- `handleInvite` uses 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
+
+1. **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. `SendFederated` treats that 403 like `unknown_sender`:
+ register (full price) and retry once. Test in Task 2.
+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 `ErrNoKeymailMailbox` whatever the status. Test in Task 2.
+3. **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.
+4. **The identity is not verified yet** (fresh deployment, DNS still
+ converging). A person expects the hand-off link, not a 500. `Send`
+ returns `ErrConsoleMailNotReady`, `handleInvite` falls through. Tests in
+ Tasks 3 and 4.
+5. **Someone creates an app named `console-mail` in `bab` and deletes
+ it.** A person expects the console's mail identity to survive. Creation
+ is refused at `claimApp`. 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 into `sealEnvelope`, 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,
+ `claimApp` reservation, `handleInvite`.
+- Modify `cmd/console/main.go`: read `CARLOS_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.json` in the worktree and commit it:
+
+```bash
+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**
+
+```go
+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**
+
+```go
+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**
+
+```bash
+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); existing
+ `sealToBox`, `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 }`
+ with `Error() 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.** In `keymail.go`,
+ replace the non-200 branch so the message text stays byte-identical:
+
+```go
+// 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()`:
+
+```go
+ 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 `sealEnvelope`** from `SendApp` (same file), and
+ make `SendApp` call it. Behaviour is unchanged:
+
+```go
+// 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:
+
+```go
+ 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, nil
+```
+
+Run: `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 and `powBits: 8` in
+ `newFakeKeymail`, and register the routes:
+
+```go
+ 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`:
+
+```go
+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:
+
+```go
+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`:
+
+```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`**
+
+```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**
+
+```bash
+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.EmailSender` interface: `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/console` calls 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):
+
+```go
+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`:
+
+```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`**
+
+```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):
+
+```go
+ // 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**
+
+```bash
+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` (`handleSend` enforces the
+ one-recipient rule)
+- Modify: `internal/console/server_account_test.go`
+ (`TestInviteAcceptCreatesMembership` reads `fedBodies`)
+- 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 to `s.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:
+
+```go
+ // 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:
+
+```go
+ // 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`:
+
+```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.New
+```
+
+Drop 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):
+
+```go
+ // ConsoleMail is the console's own email sender (consolemail.go), the
+ // invite fallback when Keymail cannot deliver. nil: no email fallback.
+ ConsoleMail ConsoleMailer
+```
+
+Add to `Server`:
+
+```go
+ // 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.Duration
+```
+
+In `NewServer`, after `s.sendApp = s.km.SendApp`:
+
+```go
+ s.sendFederated = s.km.SendFederated
+```
+
+and 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):
+
+```go
+ 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`:
+
+```go
+// 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:
+
+```go
+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**
+
+```bash
+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/console` test file holding the `mailBackends`
+ tests (`grep -ln mailBackends cmd/console/*_test.go`)
+
+**Interfaces:**
+- Consumes: `console.ConsoleMailer`; `mail.NewSESEmailSender`. The
+ constructor must be exported for `cmd/console`, so in
+ `internal/console/consolemail.go` add:
+
+```go
+// 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))` in `cmd/console/main.go`.
+
+- [ ] **Step 1: Write the failing test** next to the `mailBackends`
+ tests:
+
+```go
+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`, below `mailBackends`:
+
+```go
+// 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`:
+
+```go
+ 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
+ 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**
+
+```bash
+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**
+
+```hcl
+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.tf` after `host_store`:
+
+```hcl
+# 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**
+
+```bash
+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**
+
+```bash
+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/console
+```
+
+Expected: all PASS, builds clean.
+
+- [ ] **Step 2: Document the variable** wherever `CARLOS_MAIL_REGION` is
+ documented for operators (`grep -rn CARLOS_MAIL_REGION docs README.md`).
+ If nowhere, add one paragraph to the console section of README.md naming
+ `CARLOS_CONSOLE_MAIL_FROM`, its default and the `region-host` variables.
+ Paul's copy review covers README prose, so run the copy-review skill on
+ that paragraph before committing it.
+
+- [ ] **Step 3: Push and describe**
+
+```bash
+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-delivery
+```
+
+Merging is Paul's call (`amadan branch merge carlos/platform
+design-console-invite-delivery -squash -expect <full sha>`), after the
+final review.
diff --git a/docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md b/docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md
new file mode 100644
index 000000000..225daf3ee
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md
@@ -0,0 +1,267 @@
+# 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.
diff --git a/infra/modules/region-host/main.tf b/infra/modules/region-host/main.tf
index b58458f7d..b35ecc196 100644
--- a/infra/modules/region-host/main.tf
+++ b/infra/modules/region-host/main.tf
@@ -696,6 +696,36 @@ resource "aws_iam_role_policy" "host_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 }
+ }
+ }]
+ })
+ lifecycle {
+ precondition {
+ condition = var.console_mail_region != ""
+ error_message = "console_mail_region must be set when console_mail_from is (it must equal the console's CARLOS_MAIL_REGION)."
+ }
+ }
+}
+
# ---- object storage scanning (opt-in) --------------------------------------
#
# The one grant that lets this role read tenant object bytes, so it is its own
diff --git a/infra/modules/region-host/tests/console_mail.tftest.hcl b/infra/modules/region-host/tests/console_mail.tftest.hcl
new file mode 100644
index 000000000..d6ba650c8
--- /dev/null
+++ b/infra/modules/region-host/tests/console_mail.tftest.hcl
@@ -0,0 +1,105 @@
+# Plan-time tests for the console mail grant. mock_provider means no AWS account
+# and no credentials: these assert what the module WOULD create.
+mock_provider "aws" {}
+
+override_data {
+ target = data.aws_vpcs.default
+ values = { ids = ["vpc-0aaaaaaaaaaaaaaaa"] }
+}
+
+override_data {
+ target = data.aws_caller_identity.current
+ values = { account_id = "123456789012" }
+}
+
+# The termination queue's ARN feeds an EventBridge target, which the provider
+# validates as an ARN; the mock's random string is not one.
+override_resource {
+ target = aws_sqs_queue.terminations
+ values = { arn = "arn:aws:sqs:eu-west-1:123456789012:carlos-test-host-terminations" }
+}
+
+variables {
+ name = "carlos-test-ie"
+ sg_description = "carlos test host"
+ iam_name = "carlos-test-host"
+ instance_type = "t4g.small"
+ root_volume_size = 20
+ user_data = "#!/bin/sh\n"
+ deployment_bucket_arn = "arn:aws:s3:::carlos-test-platform"
+ data_bucket_arn = "arn:aws:s3:::carlos-test-ie"
+ ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/carlos/*"
+}
+
+run "off_by_default" {
+ command = plan
+ assert {
+ condition = length(aws_iam_role_policy.host_console_mail) == 0
+ error_message = "console_mail_from empty must create no policy"
+ }
+}
+
+run "from_and_region_set_creates_policy" {
+ command = plan
+ variables {
+ console_mail_from = "noreply@console-mail.bab.example.com"
+ console_mail_region = "eu-west-1"
+ }
+ assert {
+ condition = length(aws_iam_role_policy.host_console_mail) == 1
+ error_message = "From + region set must create one policy"
+ }
+ assert {
+ condition = aws_iam_role_policy.host_console_mail[0].name == "carlos-test-host-console-mail"
+ error_message = "the policy is named <iam_name>-console-mail"
+ }
+ assert {
+ condition = toset(jsondecode(aws_iam_role_policy.host_console_mail[0].policy).Statement[0].Action) == toset([
+ "ses:SendEmail",
+ ])
+ error_message = "the only action is ses:SendEmail"
+ }
+ assert {
+ condition = contains(
+ jsondecode(aws_iam_role_policy.host_console_mail[0].policy).Statement[0].Resource,
+ "arn:aws:ses:eu-west-1:123456789012:identity/console-mail.bab.example.com"
+ )
+ error_message = "must contain identity ARN with domain extracted from From address"
+ }
+ assert {
+ condition = contains(
+ jsondecode(aws_iam_role_policy.host_console_mail[0].policy).Statement[0].Resource,
+ "arn:aws:ses:eu-west-1:123456789012:configuration-set/carlos-bab"
+ )
+ error_message = "must contain configuration-set/carlos-bab ARN"
+ }
+ assert {
+ condition = contains(
+ jsondecode(aws_iam_role_policy.host_console_mail[0].policy).Statement[0].Resource,
+ "arn:aws:ses:eu-west-1:123456789012:tenant/carlos-bab/*"
+ )
+ error_message = "must contain tenant/carlos-bab/* ARN"
+ }
+ assert {
+ condition = jsondecode(aws_iam_role_policy.host_console_mail[0].policy).Statement[0].Condition.StringEquals["ses:FromAddress"] == "noreply@console-mail.bab.example.com"
+ error_message = "must have ses:FromAddress condition matching the From address"
+ }
+}
+
+run "from_set_without_region_fails_precondition" {
+ command = plan
+ variables {
+ console_mail_from = "noreply@console-mail.bab.example.com"
+ console_mail_region = ""
+ }
+ expect_failures = [aws_iam_role_policy.host_console_mail]
+}
+
+run "uppercase_from_fails_validation" {
+ command = plan
+ variables {
+ console_mail_from = "noreply@console-mail.bab.EXAMPLE.COM"
+ console_mail_region = "eu-west-1"
+ }
+ expect_failures = [var.console_mail_from]
+}
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf
index dfdd197c9..cfe641067 100644
--- a/infra/modules/region-host/variables.tf
+++ b/infra/modules/region-host/variables.tf
@@ -217,3 +217,19 @@ variable "relay" {
type = bool
default = false
}
+
+variable "console_mail_from" {
+ description = "The console's own From address (CARLOS_CONSOLE_MAIL_FROM, or noreply@console-mail.bab.<platform suffix>). Empty: the host role gets no ses:SendEmail and the console sends no email of its own."
+ type = string
+ default = ""
+ validation {
+ condition = var.console_mail_from == "" || can(regex("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$", var.console_mail_from))
+ error_message = "console_mail_from must be one lowercase email address, the exact From the console sends (CARLOS_CONSOLE_MAIL_FROM, or noreply@console-mail.bab.<platform suffix>)."
+ }
+}
+
+variable "console_mail_region" {
+ description = "SES region the console sends from (CARLOS_MAIL_REGION). Required when console_mail_from is set."
+ type = string
+ default = ""
+}
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index afd407bca..3a0f37384 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -782,16 +782,18 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
return
}
// Normalized exactly as the create doors normalize (normalizeAppName),
- // and refused exactly where claimApp refuses: "new" is the create form's
- // own literal route, and an app renamed onto it would be unreachable —
- // the mux prefers the literal, so no console page could ever show it.
+ // and refused exactly where claimApp refuses (reservedAppName): "new" is
+ // the create form's own literal route, and an app renamed onto it would
+ // be unreachable — the mux prefers the literal, so no console page could
+ // ever show it; consoleMailApp in the ops account is the console's own
+ // mail referrer.
to := normalizeAppName(r.FormValue("to"))
if !release.ValidApp(to) {
s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
"App names are lowercase letters, digits and dashes.", map[string]string{"rename": r.FormValue("to")})
return
}
- if to == "new" {
+ if reservedAppName(acct, to) {
s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
"That name is reserved.", map[string]string{"rename": r.FormValue("to")})
return
diff --git a/internal/console/consolemail.go b/internal/console/consolemail.go
new file mode 100644
index 000000000..b0a1a3cc3
--- /dev/null
+++ b/internal/console/consolemail.go
@@ -0,0 +1,177 @@
+// 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.
+package console
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "strings"
+ "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.
+// reservedAppName refuses it in the ops account, and the mail layer refuses
+// to tear down its domain, so no app delete can take the identity with it.
+const consoleMailApp = mail.ConsoleMailApp
+
+// 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 at console-mail under the ops account's platform name, else
+// "" (no email). Not "console.bab": that is also the platform mail name of
+// an ops app called "console".
+func ConsoleMailFrom(explicit, platformSuffix string) string {
+ if e := strings.ToLower(strings.TrimSpace(explicit)); e != "" {
+ return e
+ }
+ if platformSuffix == "" {
+ return ""
+ }
+ return "noreply@" + mail.ConsoleMailApp + "." + 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
+
+ // lock is a one-slot mutex a waiter can give up on: an ensure stalled
+ // inside an AWS call must not hold a Send past its own deadline.
+ // Everything below it is guarded by it.
+ lock chan struct{}
+ 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,
+ lock: make(chan struct{}, 1)}
+}
+
+// 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)
+ }
+ }
+}
+
+// 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 {
+ if err := m.acquire(ctx); err != nil {
+ return err
+ }
+ defer m.unlock()
+ return m.ensureLocked(ctx)
+}
+
+// acquire takes m.lock, or gives up with ErrConsoleMailNotReady when ctx
+// ends first.
+func (m *sesConsoleMailer) acquire(ctx context.Context) error {
+ select {
+ case m.lock <- struct{}{}:
+ return nil
+ case <-ctx.Done():
+ return fmt.Errorf("%w: %w", ErrConsoleMailNotReady, ctx.Err())
+ }
+}
+
+func (m *sesConsoleMailer) unlock() { <-m.lock }
+
+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) {
+ if err := m.acquire(ctx); err != nil {
+ return "", err
+ }
+ defer m.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 {
+ // The domain record (or its claim) is gone — e.g. an unverified
+ // claim lapsed (mail.ClaimLapse) and was taken, or the record was
+ // otherwise removed from under this mailer. m.ensured must not
+ // stay true: left set, every future call would short-circuit
+ // ensureLocked and this mailer would refuse forever, with no
+ // recheck ever able to repair it. Clearing it here means the NEXT
+ // recheck (after consoleMailPendingRecheck) re-runs EnsureDomain
+ // instead.
+ m.ensured = false
+ 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
+}
diff --git a/internal/console/consolemail_test.go b/internal/console/consolemail_test.go
new file mode 100644
index 000000000..3130f63bb
--- /dev/null
+++ b/internal/console/consolemail_test.go
@@ -0,0 +1,278 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+ "strings"
+ "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@shop.example", "oncarlos.test", "invites@shop.example"},
+ {"", "oncarlos.test", "noreply@console-mail.bab.oncarlos.test"},
+ {"", "", ""},
+ {" Invites@Shop.example ", "", "invites@shop.example"},
+ }
+ 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-mail.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-mail.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-mail.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-mail.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-mail.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)
+ }
+}
+
+// TestConsoleMailerRecoversFromALapsedDomainRecord covers the ruling added
+// alongside mail #216: ready() must clear m.ensured when VerifyDomain errors
+// (the domain record, or its claim, is gone — e.g. an unverified claim
+// lapsed, mail.ClaimLapse), so the NEXT recheck re-runs EnsureDomain instead
+// of failing forever on a stale "ensured" latch.
+func TestConsoleMailerRecoversFromALapsedDomainRecord(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ p := mail.NewMemProvisioner("1") // identities stay PENDING until driven verified below
+ ses := &fakeSES{}
+ dep := testMailDeployment()
+ m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), dep, ses)
+ now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
+ m.now = func() time.Time { return now }
+
+ // A successful Ensure writes the domain record and latches m.ensured,
+ // same as the console's startup call would.
+ if err := m.Ensure(ctx); err != nil {
+ t.Fatalf("ensure: %v", err)
+ }
+
+ key := mail.DomainKey(release.OpsAccount, m.domain)
+ if _, _, err := s.Get(ctx, key); err != nil {
+ t.Fatalf("domain record missing before deletion: %v", err)
+ }
+ // The record (or its claim) is gone — the shape an unverified claim's
+ // lapse (mail.ClaimLapse) leaves behind. m.ensured is still true, so
+ // without the ruling's fix ready() would call ensureLocked, find
+ // m.ensured latched, skip EnsureDomain entirely, and fail this way
+ // FOREVER — no recheck would ever repair it.
+ if err := s.Delete(ctx, key); err != nil {
+ t.Fatalf("delete domain record: %v", err)
+ }
+
+ now = now.Add(consoleMailPendingRecheck + time.Second)
+ if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
+ t.Fatalf("send after lapse = %v, want ErrConsoleMailNotReady", err)
+ }
+
+ // Drive the (still-extant, provisioner-side) identity to verified, the
+ // same seam TestConsoleMailerRechecksPendingAfterFiveMinutes uses.
+ for k := range p.Identities {
+ p.Identities[k].Status = verifiedStatus
+ }
+
+ now = now.Add(consoleMailPendingRecheck + time.Second)
+ if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil {
+ t.Fatalf("send after recheck re-ran EnsureDomain: %v", err)
+ }
+ if _, _, err := s.Get(ctx, key); err != nil {
+ t.Fatalf("domain record should exist again after re-ensure: %v", 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, regionReq{}); got != claimReserved {
+ t.Fatalf("claimApp(bab, %q) = %v, want claimReserved", consoleMailApp, got)
+ }
+}
+
+// Final review Important #1: rename was the other door onto the reserved
+// name. Refused in the ops account, the same words as "new".
+func TestRenameOntoConsoleMailIsRefusedInOps(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"),
+ url.Values{"csrf": {csrf}, "to": {consoleMailApp}, "confirmed": {"1"}})
+ if status != http.StatusUnprocessableEntity || !strings.Contains(body, "That name is reserved.") {
+ t.Fatalf("rename onto %q = %d, want 422 with the reserved refusal:\n%s", consoleMailApp, status, body)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, consoleMailApp); err != nil || has {
+ t.Fatalf("HasAppIn(%s) = %v %v, want no rename", consoleMailApp, has, err)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || !has {
+ t.Fatalf("HasAppIn(sprout) = %v %v, want the app untouched", has, err)
+ }
+}
+
+func TestReservedAppName(t *testing.T) {
+ ops := &Account{Sqid: release.OpsAccount}
+ other := &Account{Sqid: "xyz"}
+ for _, tc := range []struct {
+ acct *Account
+ name string
+ want bool
+ }{
+ {ops, "new", true},
+ {other, "new", true},
+ {ops, consoleMailApp, true},
+ {other, consoleMailApp, false},
+ {ops, "blog", false},
+ } {
+ if got := reservedAppName(tc.acct, tc.name); got != tc.want {
+ t.Errorf("reservedAppName(%s, %q) = %v, want %v", tc.acct.Sqid, tc.name, got, tc.want)
+ }
+ }
+}
+
+// stallingProvisioner never answers EnsureTenant until released, whatever
+// the context says: the AWS SDK call that hangs.
+type stallingProvisioner struct {
+ *mail.MemProvisioner
+ entered chan struct{}
+ release chan struct{}
+}
+
+func (p *stallingProvisioner) EnsureTenant(context.Context, string) error {
+ close(p.entered)
+ <-p.release
+ return nil
+}
+
+// Final review Important #3: an ensure stalled inside the provider holds the
+// mailer; a Send must still give up at its own deadline, not the ensure's.
+func TestConsoleMailerSendHonoursItsDeadlineWhileEnsureHolds(t *testing.T) {
+ p := &stallingProvisioner{MemProvisioner: mail.NewMemProvisioner("1"),
+ entered: make(chan struct{}), release: make(chan struct{})}
+ m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", release.NewDirStore(t.TempDir()), p,
+ dnsrec.NewMemWriter(), testMailDeployment(), &fakeSES{})
+ ensured := make(chan struct{})
+ go func() { defer close(ensured); m.Ensure(context.Background()) }()
+ // Let the ensure finish before TempDir's cleanup removes its store.
+ defer func() { close(p.release); <-ensured }()
+ <-p.entered
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() { done <- m.Send(ctx, "bill@gmail.com", "s", "b") }()
+ select {
+ case err := <-done:
+ if !errors.Is(err, ErrConsoleMailNotReady) || !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("Send while the ensure holds = %v, want ErrConsoleMailNotReady wrapping DeadlineExceeded", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Send blocked behind a stalled ensure past its own deadline")
+ }
+}
diff --git a/internal/console/invite_delivery_test.go b/internal/console/invite_delivery_test.go
new file mode 100644
index 000000000..1bd1d9457
--- /dev/null
+++ b/internal/console/invite_delivery_test.go
@@ -0,0 +1,373 @@
+package console
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "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
+}
+
+// sentCopy reads sent under the lock: Send runs on the server's goroutine.
+func (f *fakeConsoleMail) sentCopy() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.sent...)
+}
+
+// inviteFlashAfter reads the flash cookie the last redirect set.
+func inviteFlashAfter(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 fails, email delivers", to: "sam@keymail.dev", mail: &fakeConsoleMail{},
+ keymail: func(fx *consoleFixture) { fx.fk.powBits = maxFederationPowBits + 1 },
+ wantKind: "ok", wantInMsg: "emailed", wantMail: true},
+ // A recipient who blocked the console said no; email to the same
+ // address would go round them. Straight to the hand-off.
+ {name: "blocked is final", to: "sam@keymail.dev", mail: &fakeConsoleMail{},
+ keymail: func(fx *consoleFixture) { fx.fk.blocked["console@"+OriginHost(fx.ts.URL)] = true },
+ wantKind: "err", wantInMsg: "/invite/"},
+ {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 := inviteFlashAfter(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)
+ }
+ var sent []string
+ if c.mail != nil {
+ sent = c.mail.sentCopy()
+ }
+ mailed := strings.Contains(strings.Join(sent, "\n"), c.to+"|") &&
+ strings.Contains(strings.Join(sent, "\n"), link)
+ if mailed != c.wantMail {
+ t.Errorf("emailed = %v, want %v", mailed, c.wantMail)
+ }
+ if !c.wantMail && len(sent) != 0 {
+ t.Errorf("email sent when it should not have been: %v", sent)
+ }
+ })
+ }
+}
+
+// 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 sent := mailer.sentCopy(); len(sent) != 1 {
+ t.Fatalf("email leg not tried after a hung keymail: %v", sent)
+ }
+ if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "ok" || !strings.Contains(msg, "emailed") {
+ t.Fatalf("flash = %q %q, want ok containing %q", kind, msg, "emailed")
+ }
+}
+
+// Pins the approved copy (Paul's exact strings) so a future edit is visible
+// in a diff rather than discovered by a user.
+func TestInviteCopyHelpers(t *testing.T) {
+ if got, want := inviteFlashKeymail("sam@keymail.dev"), "Invited sam@keymail.dev. The link is in their Keymail inbox."; got != want {
+ t.Errorf("inviteFlashKeymail = %q, want %q", got, want)
+ }
+ if got, want := inviteFlashEmail("sam@keymail.dev"), "Invited sam@keymail.dev. We emailed them the link."; got != want {
+ t.Errorf("inviteFlashEmail = %q, want %q", got, want)
+ }
+ if got, want := inviteFlashFailed("sam@keymail.dev", "https://console.example/invite/tok"),
+ "Invited sam@keymail.dev, but we couldn't deliver the link. Send it to them yourself: https://console.example/invite/tok"; got != want {
+ t.Errorf("inviteFlashFailed = %q, want %q", got, want)
+ }
+ // DRAFTS under copy review (final-review F2): swap the approved text in
+ // the helpers and here.
+ if got, want := inviteFlashTooManyPending(), "This account already has 20 invites waiting. Revoke some before you send more."; got != want {
+ t.Errorf("inviteFlashTooManyPending = %q, want %q", got, want)
+ }
+ if got, want := inviteFlashTooManyPerHour(), "This account has sent 20 invites in the last hour. Try again later."; got != want {
+ t.Errorf("inviteFlashTooManyPerHour = %q, want %q", got, want)
+ }
+ wantSubject := "You've been invited to an account on the CARLOS platform"
+ wantBody := "paul@keymail.dev invited you to the account \"ops\" on the CARLOS platform.\n\n" +
+ "Open this link while signed in as sam@keymail.dev to accept. It works once and expires in 7 days:\n\n" +
+ "https://console.example/invite/tok\n\n" +
+ "If you weren't expecting this, ignore it. Nothing happens without the link."
+ gotSubject, gotBody := inviteMessage("paul@keymail.dev", "ops", "sam@keymail.dev", "https://console.example/invite/tok")
+ if gotSubject != wantSubject {
+ t.Errorf("inviteMessage subject = %q, want %q", gotSubject, wantSubject)
+ }
+ if gotBody != wantBody {
+ t.Errorf("inviteMessage body = %q, want %q", gotBody, wantBody)
+ }
+}
+
+// inviteAttempts swaps both delivery legs for counters, so a refusal can
+// prove nothing was even tried.
+func inviteAttempts(fx *consoleFixture) (fed *int, mailer *fakeConsoleMail) {
+ n := 0
+ fx.srv.sendFederated = func(context.Context, string, string, string) (string, error) {
+ n++
+ return "id", nil
+ }
+ mailer = &fakeConsoleMail{}
+ fx.srv.consoleMail = mailer
+ return &n, mailer
+}
+
+// Final review Important #2: an account cannot pile up more than
+// maxPendingInvites unaccepted, unexpired invites.
+func TestInviteRefusedAtThePendingCap(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-owner")
+ for i := 0; i < maxPendingInvites; i++ {
+ if _, err := fx.st.CreateInvite(OpsAccountID, fmt.Sprintf("p%d@keymail.dev", i), "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ fed, mailer := inviteAttempts(fx)
+ csrf := sessionToken(t, fx, owner)
+ postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
+ if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "err" || msg != inviteFlashTooManyPending() {
+ t.Fatalf("flash = %q %q, want err %q", kind, msg, inviteFlashTooManyPending())
+ }
+ if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != maxPendingInvites {
+ t.Fatalf("pending = %d, want %d (no new row)", len(pending), maxPendingInvites)
+ }
+ if *fed != 0 || len(mailer.sentCopy()) != 0 {
+ t.Fatalf("delivery attempted: keymail %d, email %v", *fed, mailer.sentCopy())
+ }
+}
+
+// An expired invite no longer counts against the cap.
+func TestInvitePendingCapIgnoresExpired(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-owner")
+ old := time.Now().Add(-InviteTTL - time.Hour)
+ for i := 0; i < maxPendingInvites; i++ {
+ if _, err := fx.st.CreateInvite(OpsAccountID, fmt.Sprintf("p%d@keymail.dev", i), "paul@keymail.dev", old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ inviteAttempts(fx)
+ csrf := sessionToken(t, fx, owner)
+ postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
+ if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "ok" {
+ t.Fatalf("flash = %q %q, want ok", kind, msg)
+ }
+}
+
+// At most maxInvitesPerHour invite rows per account per hour, whatever
+// became of them; the window slides.
+func TestInviteHourlyLimitSlides(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-owner")
+ now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
+ fx.srv.inviteRate.now = func() time.Time { return now }
+ fed, mailer := inviteAttempts(fx)
+ csrf := sessionToken(t, fx, owner)
+ invite := func(to string) (string, string) {
+ postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {to}})
+ return inviteFlashAfter(t, owner, fx.ts.URL)
+ }
+ for i := 0; i < maxInvitesPerHour; i++ {
+ if kind, msg := invite(fmt.Sprintf("p%d@keymail.dev", i)); kind != "ok" {
+ t.Fatalf("invite %d: flash = %q %q, want ok", i, kind, msg)
+ }
+ // Revoked straight away, so the pending cap is never what refuses.
+ pending, _ := fx.st.PendingInvites(OpsAccountID)
+ for _, p := range pending {
+ if err := fx.st.RevokeInvite(p.Token); err != nil {
+ t.Fatal(err)
+ }
+ }
+ now = now.Add(time.Minute)
+ }
+ *fed = 0
+ if kind, msg := invite("late@keymail.dev"); kind != "err" || msg != inviteFlashTooManyPerHour() {
+ t.Fatalf("flash = %q %q, want err %q", kind, msg, inviteFlashTooManyPerHour())
+ }
+ if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != 0 {
+ t.Fatalf("a refused invite created a row: %+v", pending)
+ }
+ if *fed != 0 || len(mailer.sentCopy()) != 0 {
+ t.Fatalf("delivery attempted: keymail %d, email %v", *fed, mailer.sentCopy())
+ }
+ // The first invite went out at 12:00; an hour and a second later it has
+ // left the window and one more is allowed.
+ now = time.Date(2026, 10, 2, 13, 0, 1, 0, time.UTC)
+ if kind, msg := invite("later@keymail.dev"); kind != "ok" {
+ t.Fatalf("after the window slid: flash = %q %q, want ok", kind, msg)
+ }
+}
+
+// The window counts rows created, not deliveries: an invite nobody could
+// deliver still used a slot.
+func TestInviteWindowCountsUndeliveredInvites(t *testing.T) {
+ var w inviteWindow
+ now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
+ w.now = func() time.Time { return now }
+ for i := 0; i < maxInvitesPerHour; i++ {
+ if !w.take(7) {
+ t.Fatalf("take %d refused", i)
+ }
+ }
+ if w.take(7) {
+ t.Fatal("take past the limit allowed")
+ }
+ if !w.take(8) {
+ t.Fatal("another account shares the window")
+ }
+ now = now.Add(time.Hour + time.Nanosecond)
+ if !w.take(7) {
+ t.Fatal("window did not slide")
+ }
+}
+
+// ValidAddress lets through strings an SES To header reads as more than one
+// recipient, or as a display name. The invite takes one bare address.
+func TestInviteRefusesAnythingButOneBareAddress(t *testing.T) {
+ for _, to := range []string{"a@x.com,b@y.com", "x<v@y.com>"} {
+ t.Run(to, func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-owner")
+ fed, mailer := inviteAttempts(fx)
+ csrf := sessionToken(t, fx, owner)
+ postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {to}})
+ if kind, msg := inviteFlashAfter(t, owner, fx.ts.URL); kind != "err" || msg != "That doesn't look like a keymail address." {
+ t.Fatalf("flash = %q %q, want the address refusal", kind, msg)
+ }
+ if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != 0 {
+ t.Fatalf("invite row created for %q: %+v", to, pending)
+ }
+ if *fed != 0 || len(mailer.sentCopy()) != 0 {
+ t.Fatalf("delivery attempted for %q", to)
+ }
+ })
+ }
+}
+
+// No Keymail mailbox is the ordinary road to email, not a failure: it logs
+// as such.
+func TestInviteLogsNoMailboxQuietly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.consoleMail = &fakeConsoleMail{}
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-owner")
+ csrf := sessionToken(t, fx, owner)
+ var buf bytes.Buffer
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+ postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@gmail.com"}})
+ log.SetOutput(os.Stderr)
+ got := buf.String()
+ if !strings.Contains(got, "console: invite to sam@gmail.com: no keymail mailbox, trying email") {
+ t.Fatalf("log = %q, want the quiet no-mailbox line", got)
+ }
+ if strings.Contains(got, "by keymail:") {
+ t.Fatalf("log = %q, still logs a no-mailbox lookup as a keymail failure", got)
+ }
+}
diff --git a/internal/console/keymail.go b/internal/console/keymail.go
index d149e4b8c..f85ab0f6c 100644
--- a/internal/console/keymail.go
+++ b/internal/console/keymail.go
@@ -38,6 +38,19 @@ import (
// road back is the OAuth ceremony, which rotates the grant.
var ErrGrantRevoked = errors.New("keymail: grant revoked or invalid")
+// 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)
+}
+
// Keymail is a client bound to one keymail base URL and one console origin.
type Keymail struct {
base string // keymail base URL, no trailing slash
@@ -229,6 +242,34 @@ const (
envSigContext = "keymail-env-v1"
)
+// 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 delivers one sealed message (kind "app" → the user's Apps inbox)
// to the granting user, using the stored grant token. It looks up the
// recipient's box key, seals {subject, body} to it, signs the envelope with
@@ -249,28 +290,10 @@ func (k *Keymail) SendApp(ctx context.Context, token, to, subject, body string)
if _, err := k.do(ctx, http.MethodGet, lu, "", nil, &lr); err != nil {
return "", fmt.Errorf("keymail: lookup %s: %w", to, err)
}
- payload, err := json.Marshal(map[string]string{"subject": subject, "body": body})
- if err != nil {
- return "", err
- }
- blob, err := sealToBox(lr.BoxPub, payload)
- if err != nil {
- return "", fmt.Errorf("keymail: seal to %s: %w", to, err)
- }
- idb := make([]byte, 16)
- if _, err := rand.Read(idb); err != nil {
- return "", err
- }
- id := hex.EncodeToString(idb)
- date := time.Now().UTC().Format(time.RFC3339)
- sig, err := signRaw(k.signKey, envelopeMsg(id, from, to, date, blob))
+ env, err := k.sealEnvelope(from, to, lr.BoxPub, subject, body)
if err != nil {
return "", err
}
- env := envelope{
- V: protocolVersion, ID: id, From: from, To: to, Date: date,
- SignPub: k.SignPub(), Blob: blob, Sig: sig,
- }
status, err := k.do(ctx, http.MethodPost, "/api/app/send", token,
map[string]any{"envelope": env, "kind": "app"}, nil)
if status == http.StatusUnauthorized {
@@ -279,7 +302,7 @@ func (k *Keymail) SendApp(ctx context.Context, token, to, subject, body string)
if err != nil {
return "", err
}
- return id, nil
+ return env.ID, nil
}
// OriginHost strips the scheme off an origin: the host (with port, if any)
@@ -330,7 +353,7 @@ func (k *Keymail) do(ctx context.Context, method, path, bearer string, in, out a
if json.Unmarshal(raw, &e) == nil && e.Error != "" {
msg = e.Error
}
- return res.StatusCode, fmt.Errorf("keymail: %s %s: %s", method, path, msg)
+ return res.StatusCode, &keymailError{Status: res.StatusCode, Method: method, Path: path, Msg: msg}
}
if out != nil {
if err := json.Unmarshal(raw, out); err != nil {
diff --git a/internal/console/keymail_test.go b/internal/console/keymail_test.go
index 444d2d4cd..279e34bce 100644
--- a/internal/console/keymail_test.go
+++ b/internal/console/keymail_test.go
@@ -54,6 +54,23 @@ type fakeKeymail struct {
reauth bool
authTime time.Time
stepUp bool
+
+ // Federation (federation.go / relay.go proxyAPI). fedSends is every
+ // envelope accepted over /api/federation/deliver (Kind "federation").
+ // powBits is the price this mailbox charges a sender it doesn't know yet.
+ // noMailbox marks local parts with no claimed mailbox (404 at lookup).
+ // blocked marks senders this mailbox has blocked. senders is the
+ // registered-sender table: from -> the sign_pub it proved. registrations
+ // counts successful /api/federation/register calls. challenges is the
+ // outstanding challenge-token table (the real Keymail seals the token
+ // instead of keeping a map, but the wire is the same).
+ fedSends []sentMessage
+ powBits int
+ noMailbox map[string]bool
+ blocked map[string]bool
+ senders map[string]string
+ registrations int
+ challenges map[string]fakeChallenge
}
// sentBodies is every message body the fake has accepted — how a test proves
@@ -76,13 +93,22 @@ func newFakeKeymail(t *testing.T, address string) *fakeKeymail {
}
// reauth defaults on: the deployments this console talks to are relays, and
// a test that wants the fail-closed path turns it off explicitly.
- f := &fakeKeymail{t: t, address: address, boxPriv: boxPriv, reauth: true}
+ f := &fakeKeymail{
+ t: t, address: address, boxPriv: boxPriv, reauth: true,
+ powBits: 8,
+ noMailbox: map[string]bool{},
+ blocked: map[string]bool{},
+ senders: map[string]string{},
+ }
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/keymail", f.handleWellKnown)
mux.HandleFunc("POST /api/oauth/token", f.handleToken)
mux.HandleFunc("POST /api/oauth/revoke", f.handleRevoke)
mux.HandleFunc("GET /api/federation/lookup", f.handleLookup)
mux.HandleFunc("POST /api/app/send", f.handleSend)
+ mux.HandleFunc("POST /api/federation/challenge", f.handleChallenge)
+ mux.HandleFunc("POST /api/federation/register", f.handleRegister)
+ mux.HandleFunc("POST /api/federation/deliver", f.handleDeliver)
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
@@ -175,18 +201,176 @@ func (f *fakeKeymail) handleRevoke(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
+// handleLookup routes like the relay (keymail/relay.go proxyAPI) and prices
+// like the instance (keymail/federation.go handleLookup): a host other than
+// keymail.dev never resolves (the relay only owns that one host), and an
+// unclaimed local part 404s as "no such mailbox" one level up from "no such
+// inbox" — the fake collapses the two hops into one handler.
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": r.URL.Query().Get("addr"),
+ "addr": addr,
"sign_pub": "",
"box_pub": base64.StdEncoding.EncodeToString(f.boxPriv.PublicKey().Bytes()),
- "pow_bits": 8,
+ "pow_bits": bits,
})
}
+// fakeChallenge is the outstanding-challenge record keyed by token. The real
+// Keymail seals the challenge instead of keeping a map (federation.go
+// sealChallenge/openChallenge), but the wire — and what register checks — 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 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
+ }
+ if f.blocked[raw.From] {
+ jsonErr(w, http.StatusForbidden, "blocked")
+ return
+ }
+ bits := f.powBits
+ if f.senders[raw.From] == raw.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: raw.To, from: raw.From, signPub: raw.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"})
+}
+
+// fedBodies is every message body the fake has accepted over federation —
+// how a test proves an invite reached someone who never granted the
+// console anything.
+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
+}
+
func (f *fakeKeymail) handleSend(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -203,14 +387,18 @@ func (f *fakeKeymail) handleSend(w http.ResponseWriter, r *http.Request) {
return
}
e := req.Envelope
- // Any recipient is accepted: a real keymail delivers to whoever the
- // envelope names (that is how an invite reaches a teammate). The fake has
- // one box key, so everything seals to it — the recipient is recorded, not
- // gated. From/signature/blob checks below are the ones that matter.
if e.To == "" {
jsonErr(w, http.StatusForbidden, "envelope needs a recipient")
return
}
+ // 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
+ }
if host := OriginHost(f.origin); !strings.HasSuffix(e.From, "@"+host) {
jsonErr(w, http.StatusForbidden, "envelope from must be an address on "+host)
return
diff --git a/internal/console/keymailfed.go b/internal/console/keymailfed.go
new file mode 100644
index 000000000..d3618439e
--- /dev/null
+++ b/internal/console/keymailfed.go
@@ -0,0 +1,186 @@
+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: %w", 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
+}
+
+// IsKeymailBlocked reports whether err is the recipient's mailbox refusing
+// the console because its owner blocked it (403 "blocked", at challenge or
+// deliver). That is the recipient's answer, not a delivery failure: no other
+// channel should go round it.
+func IsKeymailBlocked(err error) bool {
+ var ke *keymailError
+ return errors.As(err, &ke) && ke.Status == http.StatusForbidden && ke.Msg == "blocked"
+}
+
+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"
+}
+
+// powSlot lets one proof of work run at a time, process-wide. Each one is
+// up to maxFederationPowBits of hashing on a core, and invites can arrive
+// together.
+var powSlot = make(chan struct{}, 1)
+
+// acquirePowSlot waits for powSlot, or gives up when ctx ends so a waiting
+// invite falls through to the next channel at its deadline.
+func acquirePowSlot(ctx context.Context) (release func(), err error) {
+ select {
+ case powSlot <- struct{}{}:
+ return func() { <-powSlot }, nil
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
+
+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))
+ releasePow, err := acquirePowSlot(ctx)
+ if err != nil {
+ return fmt.Errorf("keymail: proof of work: %w", err)
+ }
+ counter, err := powMine(ctx, nonce[:], signPubRaw, ch.Bits)
+ releasePow()
+ 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
+}
diff --git a/internal/console/keymailfed_test.go b/internal/console/keymailfed_test.go
new file mode 100644
index 000000000..78b6400a1
--- /dev/null
+++ b/internal/console/keymailfed_test.go
@@ -0,0 +1,171 @@
+package console
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+)
+
+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)
+ }
+ // The lookup's own error stays reachable (%w: %w).
+ var ke *keymailError
+ if !errors.As(err, &ke) {
+ t.Errorf("SendFederated(%s) = %v, want the keymailError wrapped too", 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)
+ }
+ if !IsKeymailBlocked(err) {
+ t.Fatalf("IsKeymailBlocked(%v) = false", err)
+ }
+}
+
+func TestIsKeymailBlocked(t *testing.T) {
+ blocked := &keymailError{Status: 403, Msg: "blocked"}
+ for _, c := range []struct {
+ err error
+ want bool
+ }{
+ {nil, false},
+ {blocked, true},
+ {fmt.Errorf("keymail: deliver: %w", blocked), true},
+ {&keymailError{Status: 403, Msg: "unknown_sender"}, false},
+ {&keymailError{Status: 500, Msg: "blocked"}, false},
+ {ErrFederationPriceTooHigh, false},
+ } {
+ if got := IsKeymailBlocked(c.err); got != c.want {
+ t.Errorf("IsKeymailBlocked(%v) = %v, want %v", c.err, got, c.want)
+ }
+ }
+}
+
+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")
+ }
+}
+
+// Final review Important #2: proof of work is one core's worth at a time.
+// A second registration waits for the slot and gives up at its deadline.
+func TestPowSlotIsOneAtATimeAndHonoursTheDeadline(t *testing.T) {
+ release, err := acquirePowSlot(context.Background())
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ released := false
+ releaseOnce := func() {
+ if !released {
+ release()
+ released = true
+ }
+ }
+ defer releaseOnce()
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ start := time.Now()
+ if _, err := acquirePowSlot(ctx); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("second acquire while held = %v, want DeadlineExceeded", err)
+ }
+ if d := time.Since(start); d > time.Second {
+ t.Fatalf("waiting acquire took %v", d)
+ }
+ releaseOnce()
+ again, err := acquirePowSlot(context.Background())
+ if err != nil {
+ t.Fatalf("acquire after release: %v", err)
+ }
+ again()
+}
diff --git a/internal/console/keymailpow.go b/internal/console/keymailpow.go
new file mode 100644
index 000000000..a11f023bb
--- /dev/null
+++ b/internal/console/keymailpow.go
@@ -0,0 +1,71 @@
+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
+ }
+ }
+}
diff --git a/internal/console/keymailpow_test.go b/internal/console/keymailpow_test.go
new file mode 100644
index 000000000..66e7f3cc6
--- /dev/null
+++ b/internal/console/keymailpow_test.go
@@ -0,0 +1,73 @@
+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)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 09a3a3aa1..a54fa938f 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -20,6 +20,7 @@ import (
mrand "math/rand/v2"
"net"
"net/http"
+ netmail "net/mail"
"net/url"
"os"
"regexp"
@@ -778,6 +779,9 @@ type Config struct {
// unaffected either way: it calls mail.SetPaused directly, never
// through this door.
MailOperators []string
+ // ConsoleMail is the console's own email sender (consolemail.go), the
+ // invite fallback when Keymail cannot deliver. nil: no email fallback.
+ ConsoleMail ConsoleMailer
// RelayRegions are the AWS regions this deployment runs a STUN/TURN
// relay in (CARLOS_RELAY_REGIONS, comma-separated), IN THE ORDER an
// enabled app's CARLOS_RELAY lists them — the order every browser will
@@ -933,6 +937,16 @@ type Server struct {
// km.SendApp in production, a capture in tests. A field rather than a
// direct call so an alertPass assertion needs no keymail server.
sendApp func(ctx context.Context, token, to, subject, body string) (string, error)
+ // 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.Duration
+ // inviteRate is each account's invites created in the last hour
+ // (maxInvitesPerHour). In memory: a restart forgets it, and the pending
+ // cap still holds.
+ inviteRate inviteWindow
// metricsCache holds the result of LoadMetrics's last successful bucket
// sweep (metricsview.go). Concurrent callers within metricsCacheTTL share
// it outright; callers arriving later still get it immediately (stale-
@@ -1286,12 +1300,15 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// human running `carlos ship` by hand, tight enough that a fleet of
// slow-loris uploads against a stolen or shared token can't pile up
// goroutines and buffers without bound.
- shipSem: make(chan struct{}, 2),
- delegationZone: normalizeDelegationZone(cfg.DelegationZone),
- steeringHost: normalizeSteeringHost(cfg.SteeringHost),
- lookupCNAME: net.DefaultResolver.LookupCNAME,
- lookupTXT: cfg.LookupTXT,
- storeWalks: newStoreWalkLocks(),
+ shipSem: make(chan struct{}, 2),
+ delegationZone: normalizeDelegationZone(cfg.DelegationZone),
+ steeringHost: normalizeSteeringHost(cfg.SteeringHost),
+ lookupCNAME: net.DefaultResolver.LookupCNAME,
+ lookupTXT: cfg.LookupTXT,
+ storeWalks: newStoreWalkLocks(),
+ consoleMail: cfg.ConsoleMail,
+ inviteTimeout: 20 * time.Second,
+ inviteKeymailShare: 12 * time.Second,
// This process's identity in the relay writer lease. Random rather
// than derived from the host: two consoles on one box during a
// deploy overlap are two writers, and they must be able to tell
@@ -1309,6 +1326,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
}
if s.km != nil {
s.sendApp = s.km.SendApp
+ s.sendFederated = s.km.SendFederated
}
// No goroutine is started here: StartAlertWatcher is serve()'s job, so a
// test that constructs a Server gets a Server and nothing else.
@@ -2389,10 +2407,11 @@ func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/a/"+url.PathEscape(a.Sqid)+"/", http.StatusSeeOther)
}
-// handleInvite sends a single-use invitation into the addressee's Keymail
-// Apps inbox — the same sealed-envelope path sign-in confirmations use. The
-// platform never emails anyone: the invite arrives in the mailbox the
-// identity itself is anchored to.
+// 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.
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
addr, raw, acct, ok := s.owner(w, r)
if !ok {
@@ -2407,7 +2426,10 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/team", http.StatusSeeOther)
}
to := strings.ToLower(strings.TrimSpace(r.FormValue("address")))
- if !ValidAddress(to) {
+ // ValidAddress alone lets through "a@x.com,b@y.com" and "x<v@y.com>",
+ // which an email To header reads as two recipients or a display name.
+ // One bare address only.
+ if parsed, err := netmail.ParseAddress(to); !ValidAddress(to) || err != nil || parsed.Address != to {
back("err", "That doesn't look like a keymail address.")
return
}
@@ -2418,32 +2440,141 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
back("err", to+" is already a member of this account.")
return
}
+ // Volume limits (each invite is a send as the platform): first the
+ // account's waiting invites, then its last hour. Both refuse before a row
+ // exists or any delivery is tried.
+ if n, err := s.store.CountPendingInvites(acct.ID, time.Now()); err != nil {
+ log.Printf("console: count pending invites: %v", err)
+ back("err", "Could not create the invitation. Please try again.")
+ return
+ } else if n >= maxPendingInvites {
+ back("err", inviteFlashTooManyPending())
+ return
+ }
+ if !s.inviteRate.take(acct.ID) {
+ back("err", inviteFlashTooManyPerHour())
+ return
+ }
tok, err := s.store.CreateInvite(acct.ID, to, addr, time.Now())
if err != nil {
+ s.inviteRate.giveBack(acct.ID)
log.Printf("console: create invite: %v", err)
back("err", "Could not create the invitation. Please try again.")
return
}
link := s.cfg.Origin + "/invite/" + url.PathEscape(tok)
- // The console's own grant is the instrument: it can only send from the
- // INVITER's stored token, so an owner who has never completed the OAuth
- // ceremony cannot invite. The invite row still exists, and the owner can
- // pass the link on by hand — which is why the failure is a flash, not a
- // rollback.
- if gt, _ := s.store.GrantToken(addr); gt != "" {
- ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
- defer cancel()
- if _, err := s.km.SendApp(ctx, gt, to,
- "You've been invited to an account on the CARLOS platform",
- addr+" invited you to the account \""+acct.Name+"\" 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."); err != nil {
- log.Printf("console: invite send to %s: %v", to, err)
- back("err", "Invited "+to+", but delivering the link failed — you can send it on yourself: "+link)
+ 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
+ }
+ switch {
+ case IsKeymailBlocked(err):
+ // The recipient blocked the console. That is their answer; email
+ // to the same address would go round it.
+ log.Printf("console: invite to %s: the recipient blocked the console on keymail; not emailing", to)
+ back("err", inviteFlashFailed(to, link))
+ return
+ case errors.Is(err, ErrNoKeymailMailbox):
+ log.Printf("console: invite to %s: no keymail mailbox, trying email", to)
+ default:
+ 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))
+}
+
+// inviteMessage is the subject and body for an invitation, whichever channel
+// carries it (Keymail federation or the console's own email).
+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 we couldn't deliver the link. Send it to them yourself: " + link
+}
+
+// The two volume refusals. DRAFTS under copy review: the approved text goes
+// here and in TestInviteCopyHelpers.
+func inviteFlashTooManyPending() string {
+ return "This account already has 20 invites waiting. Revoke some before you send more."
+}
+func inviteFlashTooManyPerHour() string {
+ return "This account has sent 20 invites in the last hour. Try again later."
+}
+
+// Invite volume limits. Every invite is a message sent as the platform, to
+// an address of the owner's choosing, from an account anyone signed in can
+// create.
+const (
+ maxPendingInvites = 20
+ maxInvitesPerHour = 20
+)
+
+// inviteWindow is a per-account sliding hour of invite creations. The zero
+// value is ready to use.
+type inviteWindow struct {
+ mu sync.Mutex
+ now func() time.Time // nil: time.Now
+ sent map[int64][]time.Time
+}
+
+// take records one invite for acct and reports true, or reports false
+// without recording when acct already has maxInvitesPerHour in the last hour.
+// Check and record are one step, so concurrent invites cannot both slip
+// under the limit.
+func (w *inviteWindow) take(acct int64) bool {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ now := time.Now()
+ if w.now != nil {
+ now = w.now()
+ }
+ cutoff := now.Add(-time.Hour)
+ kept := w.sent[acct][:0]
+ for _, t := range w.sent[acct] {
+ if t.After(cutoff) {
+ kept = append(kept, t)
+ }
+ }
+ if len(kept) >= maxInvitesPerHour {
+ w.sent[acct] = kept
+ return false
+ }
+ if w.sent == nil {
+ w.sent = map[int64][]time.Time{}
+ }
+ w.sent[acct] = append(kept, now)
+ return true
+}
+
+// giveBack undoes the latest take for acct: the row it was for was never
+// created.
+func (w *inviteWindow) giveBack(acct int64) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if ts := w.sent[acct]; len(ts) > 0 {
+ w.sent[acct] = ts[:len(ts)-1]
}
- back("ok", "Invited "+to+" — the link is in their Keymail Apps inbox.")
}
func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request) {
@@ -3843,6 +3974,21 @@ func validBuildProbe(p string) bool {
// it here. See the call site for why that default matters.
func ownerExempt(prefix, ownPrefix string) bool { return prefix == ownPrefix }
+// reservedAppName reports whether no app in acct may take name, by create or
+// by rename.
+//
+// "new" is the literal path segment GET /a/{sqid}/apps/new resolves to —
+// Go 1.22's mux prefers it over apps/{app}, so an app named "new" could
+// never be viewed.
+//
+// The console's own mail identity hangs off consoleMailApp in the ops
+// account (consolemail.go). The mail layer refuses to tear that domain down
+// whatever app holds the name, but an app wearing it would still read as
+// the domain's owner everywhere else.
+func reservedAppName(acct *Account, name string) bool {
+ return name == "new" || (acct.Sqid == release.OpsAccount && name == consoleMailApp)
+}
+
// claimApp is the permanent claim every existing door makes: claimAppWith
// with a zero expiresAt.
func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (outcome claimOutcome, placed string, held *apexHold) {
@@ -3907,10 +4053,8 @@ func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, pl
if !release.ValidApp(name) {
return claimInvalid, "", nil
}
- // "new" is the literal path segment GET /a/{sqid}/apps/new resolves to —
- // Go 1.22's mux prefers it over apps/{app}, so an app named "new" could
- // never be viewed. Reserved before any store write, same as an invalid name.
- if name == "new" {
+ // Reserved before any store write, same as an invalid name.
+ if reservedAppName(acct, name) {
return claimReserved, "", nil
}
temporary := !expiresAt.IsZero()
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
index 59bdf4501..bdca9efb9 100644
--- a/internal/console/server_account_test.go
+++ b/internal/console/server_account_test.go
@@ -321,8 +321,8 @@ func TestInviteAcceptCreatesMembership(t *testing.T) {
if len(pending) != 1 || pending[0].Address != "sam@keymail.dev" {
t.Fatalf("PendingInvites = %+v", pending)
}
- // The link went into sam's Keymail inbox, not an email.
- sent := fx.fk.sentBodies()
+ // The link went into sam's Keymail inbox by federation.
+ sent := fx.fk.fedBodies()
if len(sent) == 0 || !strings.Contains(strings.Join(sent, "\n"), "/invite/"+pending[0].Token) {
t.Fatalf("invite link not delivered through keymail: %v", sent)
}
diff --git a/internal/console/store.go b/internal/console/store.go
index e301a3ebc..7bfe00f00 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -1393,6 +1393,15 @@ func (s *Store) RevokeInvite(token string) error {
return err
}
+// CountPendingInvites counts an account's unaccepted invitations that have
+// not expired at now: the ones handleInvite's cap is about.
+func (s *Store) CountPendingInvites(accountID int64, now time.Time) (int, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM invites WHERE account_id = ? AND accepted_at = 0 AND expires_at >= ?`,
+ accountID, now.Unix()).Scan(&n)
+ return n, err
+}
+
// PendingInvites lists an account's unaccepted, unexpired invitations.
func (s *Store) PendingInvites(accountID int64) ([]Invite, error) {
rows, err := s.db.Query(`SELECT token, account_id, address, invited_by, created_at, expires_at
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt
index 310cb54d3..d88304f0b 100644
--- a/internal/console/testdata/claim-authority-sites.txt
+++ b/internal/console/testdata/claim-authority-sites.txt
@@ -45,6 +45,7 @@
../mail/aws.go|NewAWSProvisioner|iam.NewFromConfig(cfg)
../mail/aws.go|NewAWSProvisioner|sesv2.NewFromConfig(cfg)
../mail/aws.go|NewAWSProvisioner|sts.NewFromConfig(cfg)
+../mail/sessend.go|NewSESEmailSender|sesv2.NewFromConfig(cfg)
apihostclaim.go|handleAPIHostResolveInspect|s.cfg.ClaimAuthority.InspectMoveLoss(r.Context(), actor, account, app, r.PathValue("host"), in.Box)
apihostclaim.go|handleAPIHostResolve|s.cfg.ClaimAuthority.DeclareMoveLoss(r.Context(), actor, account, app, r.PathValue("host"), s.claimChallengeBox(in.Submission), in.Submission)
apiinstancemove.go|claimConsoleMove|a.Control.ConsoleMove(ctx, o, action)
diff --git a/internal/mail/consolemail.go b/internal/mail/consolemail.go
new file mode 100644
index 000000000..5f3c4a0ed
--- /dev/null
+++ b/internal/mail/consolemail.go
@@ -0,0 +1,25 @@
+package mail
+
+import (
+ "errors"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ConsoleMailApp is the referrer the console's own sending domain hangs off
+// in the ops account (internal/console/consolemail.go). It is not a real app:
+// the console claims the domain under this name so that no app's estate
+// owns it. The console refuses to create or rename an app onto it, and the
+// release walks here refuse to tear its domain down, so an app that reaches
+// the name some other way still cannot take the console's identity with it.
+const ConsoleMailApp = "console-mail"
+
+// ErrConsoleMailReserved is RemoveDomain's refusal of the console's own
+// sending domain: the ops account's ConsoleMailApp referrer, or a claim that
+// referrer made.
+var ErrConsoleMailReserved = errors.New("mail: the console's own sending domain cannot be removed")
+
+// isConsoleMail reports whether acct/app is the console's own referrer.
+func isConsoleMail(acct, app string) bool {
+ return acct == release.OpsAccount && app == ConsoleMailApp
+}
diff --git a/internal/mail/consolemail_test.go b/internal/mail/consolemail_test.go
new file mode 100644
index 000000000..648764e6c
--- /dev/null
+++ b/internal/mail/consolemail_test.go
@@ -0,0 +1,118 @@
+package mail
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/dnsrec"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const consoleMailDomain = "console-mail.bab.oncarlos.com"
+
+// consoleMailFixture ensures the console's own sending domain the way the
+// console does: ops account, referrer ConsoleMailApp, a platform name.
+func consoleMailFixture(t *testing.T) (context.Context, release.Store, *MemProvisioner, *dnsrec.MemWriter) {
+ t.Helper()
+ ctx := context.Background()
+ s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared-aws-account"), dnsrec.NewMemWriter()
+ now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
+ if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp, consoleMailDomain, now); err != nil {
+ t.Fatalf("EnsureDomain: %v", err)
+ }
+ return ctx, s, p, w
+}
+
+func assertConsoleMailIntact(t *testing.T, ctx context.Context, s release.Store, p *MemProvisioner) {
+ t.Helper()
+ rec, _, err := GetDomain(ctx, s, release.OpsAccount, consoleMailDomain)
+ if err != nil || rec == nil {
+ t.Fatalf("the console's domain record is gone: %+v (err %v)", rec, err)
+ }
+ if len(rec.Apps) != 1 || rec.Apps[0] != ConsoleMailApp {
+ t.Fatalf("referrers = %v, want [%s]", rec.Apps, ConsoleMailApp)
+ }
+ if _, ok := p.Identities[identityKey("eu-west-1", consoleMailDomain)]; !ok {
+ t.Fatal("the console's SES identity was deleted")
+ }
+ if claim, err := getDomainClaim(ctx, s, consoleMailDomain); err != nil || claim == nil {
+ t.Fatalf("the console's domain claim was released: %+v (err %v)", claim, err)
+ }
+}
+
+// An app that lands on the reserved name in the ops account (a move, a
+// migrate, a row from before the reservation) is still an app, and deleting
+// it runs Teardown. That must not take the console's own sending domain
+// with it.
+func TestTeardownKeepsTheConsoleMailDomain(t *testing.T) {
+ ctx, s, p, w := consoleMailFixture(t)
+ if err := Teardown(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp); err != nil {
+ t.Fatalf("Teardown: %v", err)
+ }
+ assertConsoleMailIntact(t, ctx, s, p)
+}
+
+// The same name in any other account is an ordinary app: its Teardown
+// releases its domains as usual.
+func TestTeardownOfConsoleMailNameElsewhereIsOrdinary(t *testing.T) {
+ ctx := context.Background()
+ s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared-aws-account"), dnsrec.NewMemWriter()
+ now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
+ if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "aaa", ConsoleMailApp, "console-mail.aaa.oncarlos.com", now); err != nil {
+ t.Fatalf("EnsureDomain: %v", err)
+ }
+ if err := Teardown(ctx, s, p, w, testDeployment(), "aaa", ConsoleMailApp); err != nil {
+ t.Fatalf("Teardown: %v", err)
+ }
+ if rec, _, err := GetDomain(ctx, s, "aaa", "console-mail.aaa.oncarlos.com"); err != nil || rec != nil {
+ t.Fatalf("an ordinary app's domain survived its teardown: %+v (err %v)", rec, err)
+ }
+}
+
+func TestRemoveDomainRefusesTheConsoleMailDomain(t *testing.T) {
+ ctx, s, p, w := consoleMailFixture(t)
+ out, err := RemoveDomain(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp, consoleMailDomain)
+ if !errors.Is(err, ErrConsoleMailReserved) {
+ t.Fatalf("RemoveDomain = %+v, %v; want ErrConsoleMailReserved", out, err)
+ }
+ assertConsoleMailIntact(t, ctx, s, p)
+}
+
+// A claim with no record (an ensure that failed after claiming) is released
+// by RemoveDomain for any app in the account. The console's claim is not:
+// the console re-runs its own ensure and picks it back up.
+func TestRemoveDomainRefusesTheConsoleMailOrphanClaim(t *testing.T) {
+ ctx, s, p, w := consoleMailFixture(t)
+ if err := s.Delete(ctx, DomainKey(release.OpsAccount, consoleMailDomain)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), release.OpsAccount, "blog", consoleMailDomain); !errors.Is(err, ErrConsoleMailReserved) {
+ t.Fatalf("RemoveDomain(orphan) = %v, want ErrConsoleMailReserved", err)
+ }
+ if claim, err := getDomainClaim(ctx, s, consoleMailDomain); err != nil || claim == nil {
+ t.Fatalf("the console's orphan claim was released: %+v (err %v)", claim, err)
+ }
+ if _, ok := p.Identities[identityKey("eu-west-1", consoleMailDomain)]; !ok {
+ t.Fatal("the console's SES identity was deleted")
+ }
+}
+
+// Teardown's orphan-claim sweep is scoped to the app that made the claim,
+// so it too must skip the console's.
+func TestTeardownKeepsTheConsoleMailOrphanClaim(t *testing.T) {
+ ctx, s, p, w := consoleMailFixture(t)
+ if err := s.Delete(ctx, DomainKey(release.OpsAccount, consoleMailDomain)); err != nil {
+ t.Fatal(err)
+ }
+ if err := Teardown(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp); err != nil {
+ t.Fatalf("Teardown: %v", err)
+ }
+ if claim, err := getDomainClaim(ctx, s, consoleMailDomain); err != nil || claim == nil {
+ t.Fatalf("the console's orphan claim was swept: %+v (err %v)", claim, err)
+ }
+ if _, ok := p.Identities[identityKey("eu-west-1", consoleMailDomain)]; !ok {
+ t.Fatal("the console's SES identity was deleted")
+ }
+}
diff --git a/internal/mail/sessend.go b/internal/mail/sessend.go
new file mode 100644
index 000000000..ec1132258
--- /dev/null
+++ b/internal/mail/sessend.go
@@ -0,0 +1,45 @@
+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
+}
diff --git a/internal/mail/walk.go b/internal/mail/walk.go
index f65a9036d..7c23692b6 100644
--- a/internal/mail/walk.go
+++ b/internal/mail/walk.go
@@ -1518,6 +1518,14 @@ func claimUnserializedTeardown(ctx context.Context, s release.Store, p Provision
}
}
+ // The console's own sending domain (ConsoleMailApp) is never torn down
+ // with an app: its record, claim, identity and DNS are the console's,
+ // and an app that reached the reserved name by a move or a migrate is
+ // not their owner. Its credentials above and its records below still go.
+ if isConsoleMail(acct, app) {
+ return deleteMailRecords(ctx, s, acct, app, rec)
+ }
+
domains, err := listDomains(ctx, s, acct)
if err != nil {
return err
@@ -1617,6 +1625,11 @@ func claimUnserializedTeardown(ctx context.Context, s release.Store, p Provision
}
}
+ return deleteMailRecords(ctx, s, acct, app, rec)
+}
+
+// deleteMailRecords is Teardown's last step: the app's own email records.
+func deleteMailRecords(ctx context.Context, s release.Store, acct, app string, rec *Record) error {
if rec != nil {
// Both objects: the sender the app is on, and the legacy one the
// migration deliberately leaves standing until the sweep. Leaving
@@ -1714,6 +1727,10 @@ func claimUnserializedRemoveDomain(ctx context.Context, s release.Store, p Provi
if !ValidDomain(domain) {
return nil, fmt.Errorf("mail: invalid domain %q", domain)
}
+ // The console's own referrer never releases its domain (see Teardown).
+ if isConsoleMail(acct, app) {
+ return nil, fmt.Errorf("mail: %s: %w", domain, ErrConsoleMailReserved)
+ }
// The From check runs FIRST, before anything is read about the domain
// and long before anything is written: a refusal must cost the caller
@@ -1803,6 +1820,11 @@ func removeOrphanedClaim(ctx context.Context, s release.Store, p Provisioner,
if claim == nil || claim.Account != acct {
return nil, fmt.Errorf("mail: no sending domain %s for this account: %w", domain, ErrDomainNotReferenced)
}
+ // The console's own claim with no record is an ensure the console will
+ // retry; releasing it here would delete the identity from under it.
+ if isConsoleMail(acct, claim.App) {
+ return nil, fmt.Errorf("mail: %s: %w", domain, ErrConsoleMailReserved)
+ }
// Only an identity this claim's walk created, as in Teardown's sweep.
if claim.createdIdentityIn(d.Region) {
if err := p.DeleteIdentity(ctx, d.Region, domain); err != nil {
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 13ed05d56..13323fdb4 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -884,6 +884,18 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ // Census overlay console-invite-delivery (console invite delivery),
+ // loaded last, under census-ruling-console-invite-delivery (platform-1e,
+ // R1-R5): seven units accepted legacy under the console (carlos-console)
+ // or tofu on an operator's machine, since the root carlos binary links
+ // neither internal/console nor internal/mail (R1, pinned by
+ // TestRootBinaryLinksNoConsoleOrMail); Keymail.SendApp retired by an H3
+ // census-ruling removal (R2); the rev3 tag rewritten to the ruling's
+ // name (R4). The ruling is the scope ruling the freeze needs, and the
+ // seal moves.
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-console-invite-delivery.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
// Census overlay claim-untagged-pointer (install-channel --policy takes
// --identity, the published release identity, because the channel
// pointer carlos system update writes before a floor is published
@@ -913,6 +925,7 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix2.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ seal = "53b3cbb6f46d657ce32f49dad84dae79316c80da949ccf37273833eef5c5b2d7"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 6a7232b82..de6143aaf 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -730,7 +730,7 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/console/relayops.go", "Server.relayPublishOnce", "nonrecord", "sel:Put", "783a3c20337914e08f92be783f6b48ab14288437a8043c0d5bf29f281a7a9b1d"},
{"internal/console/relayops.go", "Server.relayRotate", "nonrecord", "sel:Put", "58e0090a4990e2d5e793cec2ec7bcbfb3626b4043307f64484a55766099e1434"},
// Re-reviewed at the merge of carlos-run into seeded-instances (2026-10-13): main's body plus the start of the seed scratch pool; no record write.
- {"internal/console/server.go", "NewServer", "key-site", "lit:instances", "c4ce8eee8845435795d010af428d162c452186c1579330688e9adc40df695d31"},
+ {"internal/console/server.go", "NewServer", "key-site", "lit:instances", "4d82a68616a6754966ced46d8d12541fcda64de9e1856c3440a127a9c533b032"},
{"internal/console/server.go", "Server.PublishMovesConsole", "nonrecord", "sel:PutReplace", "b228397d413769f93209645db9ac6bd4a47cc9db31f06c8699cb540cffea9b5a"},
{"internal/console/server.go", "Server.handleAppInstances", "key-site", "lit:instances", "7368e00fa1bf20365b61361815db216441b7fd21b3d330349dc09170045d45b7"},
{"internal/console/server.go", "Server.handleAppActivity", "key-site", "lit:instances", "265425be83a63bf2fbcd88e1b75600933cf1a2e8b7fa9cc90046fdd329db9786"},
diff --git a/internal/provision/root_binary_deps_test.go b/internal/provision/root_binary_deps_test.go
new file mode 100644
index 000000000..2ba33e469
--- /dev/null
+++ b/internal/provision/root_binary_deps_test.go
@@ -0,0 +1,56 @@
+package provision
+
+import (
+ "bytes"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+// TestRootBinaryLinksNoConsoleOrMail pins the basis of
+// census-ruling-console-invite-delivery R1 (platform-1e;
+// docs/superpowers/notes/census-proposal-console-invite-delivery.md): the
+// root carlos binary (the package at the repo root) links neither
+// internal/console nor internal/mail, so the console's units in that overlay
+// never run as root. go list -deps names the package's non-test imports,
+// direct and indirect, for each GOOS the release builds.
+func TestRootBinaryLinksNoConsoleOrMail(t *testing.T) {
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go not on PATH")
+ }
+ root := censusRoot(t)
+ banned := []string{
+ "github.com/carlosframework/platform/internal/console",
+ "github.com/carlosframework/platform/internal/mail",
+ }
+ for _, goos := range []string{"linux", "darwin", "windows"} {
+ cmd := exec.Command(goBin, "list", "-deps", "-f", "{{.ImportPath}}", ".")
+ cmd.Dir = root
+ cmd.Env = append(os.Environ(), "GOOS="+goos, "CGO_ENABLED=0")
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ t.Fatalf("go list -deps . (GOOS=%s): %v: %s", goos, err, stderr.String())
+ }
+ deps := map[string]bool{}
+ for _, line := range strings.Split(string(out), "\n") {
+ deps[strings.TrimSpace(line)] = true
+ }
+ if !deps["github.com/carlosframework/platform"] {
+ t.Fatalf("go list -deps . (GOOS=%s) did not list the root package itself", goos)
+ }
+ for _, pkg := range banned {
+ if deps[pkg] {
+ t.Errorf("GOOS=%s: the root carlos binary links %s; census-ruling-console-invite-delivery R1 assumed it does not", goos, pkg)
+ }
+ for dep := range deps {
+ if strings.HasPrefix(dep, pkg+"/") {
+ t.Errorf("GOOS=%s: the root carlos binary links %s, under %s", goos, dep, pkg)
+ }
+ }
+ }
+ }
+}
diff --git a/internal/provision/testdata/plan14-root-reads-console-invite-delivery.tsv b/internal/provision/testdata/plan14-root-reads-console-invite-delivery.tsv
new file mode 100644
index 000000000..c68a0d9f8
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-console-invite-delivery.tsv
@@ -0,0 +1,26 @@
+# Plan 14 census overlay console-invite-delivery (console invite delivery: Keymail federation first, then the console's own email, then the hand-off link; docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md), loaded last, after box-publish-record. RULED by platform-1e as census-ruling-console-invite-delivery (R1-R5), on 5aef01db; the ruling and its reasons are docs/superpowers/notes/census-proposal-console-invite-delivery.md.
+# R1: every unit here runs in the console (cmd/console serve under console.service, user carlos-console) or in tofu on an operator's machine, and each is accepted legacy under that identity. The root carlos binary links neither internal/console nor internal/mail; TestRootBinaryLinksNoConsoleOrMail (root_binary_deps_test.go) pins it with go list -deps. This ruling is the separate scope ruling the legacy freeze needs, and the seal moves.
+# R2: Keymail.SendApp is retired by an H3 census-ruling removal (the carlos-run getJSON form): its one read moved unchanged into Keymail.sealEnvelope, the same class. R3: the host role's ses:SendEmail grant is outside this census; Paul approves the host-role SES grant (or a console-only role) before console_mail_from is set. R4: the rev3 tag is census-ruling-console-invite-delivery, as box-publish-record and the step 6 overlays did. R5: not a merge blocker; with CARLOS_HOST_CLAIM on, the console mailer must go through the claim mail gate before console mail is enabled (#250).
+# Five changed units are re-frozen with their read sites unchanged (serve, NewServer, Keymail.do, and the two region-host files). Two units are new: Keymail.sealEnvelope (crypto/rand.Read, the site SendApp held) and Store.CountPendingInvites (one SQLite QueryRow).
+@legacy 56fd2bdf3b9f60d83217e5b9dc5aba1618d1187f523f01e7d9e3ab3a67c0be98 cmd/console/main.go serve legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=the console (cmd/console serve under console.service, user carlos-console): main's body with the console mailer built once (consoleMailer, over the release store, mail provisioner and DNS writer serve already holds), handed to NewServer as Config.ConsoleMail, and its identity ensure started in a goroutine bounded at two minutes beside the alert watcher; read sites unchanged: the same three /data literals (the registry and console database defaults), each moved again by the HostClaim guard (0da9b965, #250): hostClaim read once and passed as a new argument to consoleMailer, no new read site data-literal@3439,data-literal@4540,data-literal@4777 24101dff120a7c6086e39f84949a04e9f16d4d2b54bfffc9210783fd38909c44
+cmd/console/main.go serve legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=the console (cmd/console serve under console.service, user carlos-console): main's body with the console mailer built once (consoleMailer, over the release store, mail provisioner and DNS writer serve already holds), handed to NewServer as Config.ConsoleMail, and its identity ensure started in a goroutine bounded at two minutes beside the alert watcher; read sites unchanged: the same three /data literals (the registry and console database defaults), each moved again by the HostClaim guard (0da9b965, #250): hostClaim read once and passed as a new argument to consoleMailer, no new read site data-literal@3439,data-literal@4540,data-literal@4777 24101dff120a7c6086e39f84949a04e9f16d4d2b54bfffc9210783fd38909c44
+@legacy 2a3abb0b02d1f9b17ba5d964cdb9703fa962099560ad94d9fb725c169cfe9aee infra/modules/region-host/main.tf external legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=tofu on an operator's machine with the operator's AWS credentials (no box unit; no binary embeds the file): main's module with one optional aws_iam_role_policy, host_console_mail, count 0 unless console_mail_from is set; it grants the host role ses:SendEmail on the one sending identity, the carlos-bab configuration set and its tenants, conditioned on ses:FromAddress equal to console_mail_from; no read is added and nothing on a box reads the file file:external@0 6ae47ec38e0dbace114634c73575dc00970461521c3bce3e36baeb7942229c5b
+infra/modules/region-host/main.tf external legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=tofu on an operator's machine with the operator's AWS credentials (no box unit; no binary embeds the file): main's module with one optional aws_iam_role_policy, host_console_mail, count 0 unless console_mail_from is set; it grants the host role ses:SendEmail on the one sending identity, the carlos-bab configuration set and its tenants, conditioned on ses:FromAddress equal to console_mail_from; no read is added and nothing on a box reads the file file:external@0 6ae47ec38e0dbace114634c73575dc00970461521c3bce3e36baeb7942229c5b
+@legacy 1507a927643a2e468dd76599f7be431bc6c1ac4669d374c1774ccad1185bac46 infra/modules/region-host/variables.tf external legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=tofu on an operator's machine with the operator's AWS credentials (no box unit; no binary embeds the file): main's variables with console_mail_from (validated as one lowercase address) and console_mail_region, both empty by default; no read is added and nothing on a box reads the file file:external@0 ffdad585e18d905f0f4a97b9b666777200fd505868bcd5bde0cfeae216b6250e
+infra/modules/region-host/variables.tf external legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=tofu on an operator's machine with the operator's AWS credentials (no box unit; no binary embeds the file): main's variables with console_mail_from (validated as one lowercase address) and console_mail_region, both empty by default; no read is added and nothing on a box reads the file file:external@0 ffdad585e18d905f0f4a97b9b666777200fd505868bcd5bde0cfeae216b6250e
+@legacy 0a5b5cdd3c191bd712520c32910dc813906bb049b241eea61e40c13202fda136 internal/console/keymail.go Keymail.do legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=the console (cmd/console serve under console.service, user carlos-console): main's body with a non-200 answer returned as the typed keymailError (the same Error text, plus Status and Msg for IsKeymailBlocked); its one read, io.ReadAll of Keymail's HTTP response body capped at 1 MiB, unchanged file:ReadAll@594 e2f193e479472dff43846bb8d212124a1439b5765684648d499e607e30ede7be
+internal/console/keymail.go Keymail.do legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=the console (cmd/console serve under console.service, user carlos-console): main's body with a non-200 answer returned as the typed keymailError (the same Error text, plus Status and Msg for IsKeymailBlocked); its one read, io.ReadAll of Keymail's HTTP response body capped at 1 MiB, unchanged file:ReadAll@594 e2f193e479472dff43846bb8d212124a1439b5765684648d499e607e30ede7be
+@legacy 58d32b4b120d00d668186e0fb2f4e645fb363216cbc7bbc6581f0c58bc6c9af4 internal/console/keymail.go Keymail.sealEnvelope legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=the console (cmd/console serve under console.service, user carlos-console): new: SendApp's envelope sealing moved unchanged into its own method, which SendApp and SendFederated share; its one read is crypto/rand.Read of the 16-byte envelope id from the kernel's random source, the site SendApp held as file:Read@949 (accepted-legacy, J4 root-input, on main); no file, no store, no box state file:Read@387 b140fde1ca8b9b45403e55e72eebfb922ad5847c43c2ca836324ad6b46d77658
+internal/console/keymail.go Keymail.sealEnvelope legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=the console (cmd/console serve under console.service, user carlos-console): new: SendApp's envelope sealing moved unchanged into its own method, which SendApp and SendFederated share; its one read is crypto/rand.Read of the 16-byte envelope id from the kernel's random source, the site SendApp held as file:Read@949 (accepted-legacy, J4 root-input, on main); no file, no store, no box state file:Read@387 b140fde1ca8b9b45403e55e72eebfb922ad5847c43c2ca836324ad6b46d77658
+@legacy c2fa720d59cc3927e15b859e52a3241f9d6b007a5fb8a038fe7fad7be78e4d41 internal/console/server.go NewServer legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=the console (cmd/console serve under console.service, user carlos-console): main's body with the invite delivery fields set (consoleMail from Config.ConsoleMail, inviteTimeout, inviteKeymailShare) and sendFederated bound to km.SendFederated beside sendApp; no goroutine; read sites unchanged: the one data-literal is the route pattern GET /a/{sqid}/apps/{app}/logs/data, not a path data-literal@9108 4d82a68616a6754966ced46d8d12541fcda64de9e1856c3440a127a9c533b032
+internal/console/server.go NewServer legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=the console (cmd/console serve under console.service, user carlos-console): main's body with the invite delivery fields set (consoleMail from Config.ConsoleMail, inviteTimeout, inviteKeymailShare) and sendFederated bound to km.SendFederated beside sendApp; no goroutine; read sites unchanged: the one data-literal is the route pattern GET /a/{sqid}/apps/{app}/logs/data, not a path data-literal@9108 4d82a68616a6754966ced46d8d12541fcda64de9e1856c3440a127a9c533b032
+@legacy 2c2e5edb430e5d8a9f42c77f8cb9cdb875b896caaf022fef3871e8849166fedf internal/console/store.go Store.CountPendingInvites legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=the console (cmd/console serve under console.service, user carlos-console): new: one SQLite QueryRow, a COUNT(*) of an account's unaccepted, unexpired rows in the console's own invites table (CONSOLE_DB), for handleInvite's pending cap; it only refuses an invite; the same class as Store.PendingInvites, CreateInvite and RevokeInvite (accepted-legacy, J4 root-input, on main) file:QueryRow@102 1733078d53728ce629a956cf10c77e43ce9fe4fab21b1a45c408743bddb818e5
+internal/console/store.go Store.CountPendingInvites legacy accepted-legacy accepted-legacy;rev3=census-ruling-console-invite-delivery;basis=the console (cmd/console serve under console.service, user carlos-console): new: one SQLite QueryRow, a COUNT(*) of an account's unaccepted, unexpired rows in the console's own invites table (CONSOLE_DB), for handleInvite's pending cap; it only refuses an invite; the same class as Store.PendingInvites, CreateInvite and RevokeInvite (accepted-legacy, J4 root-input, on main) file:QueryRow@102 1733078d53728ce629a956cf10c77e43ce9fe4fab21b1a45c408743bddb818e5
+# R2: Keymail.SendApp retired (H3, the carlos-run form). A removal is not in the seal.
+@remove internal/console/keymail.go Keymail.SendApp
+@removed internal/console/keymail.go Keymail.SendApp H3 census-ruling-console-invite-delivery: still declared, and reads nothing now: its one read, crypto/rand.Read of the envelope id, moved unchanged into Keymail.sealEnvelope, an accepted-legacy row in this overlay. It runs in the console as carlos-console.
+# Envelopes re-pinned. cmd/console: main.go (serve, consoleMailer). infra/modules/region-host: main.tf and variables.tf (rows above). internal/console: consolemail.go, keymailfed.go and keymailpow.go are new and the scan finds no read site in them; appsettings.go, keymail.go, server.go and store.go changed, and their only read-site changes are the rows above. internal/mail: consolemail.go and sessend.go are new, walk.go gains the console-mail guards; the scan finds no read site in any of them (NewSESEmailSender's SDK config load is the console's default credential chain, the host role, and is in TestClaimAuthoritySourceCensus as a send-only client).
+@coverage cmd/console b22983f8973baaef9f8598af4669684fee7078a08b570f88dfdf90b61f15a89e
+@coverage infra/modules/region-host 82e90b1086ae4b88cc311f7cac50074de1c1636252bf5b6acdc15cc393d856e7
+@coverage internal/console 18fc22e075cea71c813348099c6698d6f73b2047ff92fc5b2f2cd52408255756
+@coverage internal/mail c45dd340c6db8c403fb002e98386c812d848dc5a4dbc526c586e3571bbf6e317