| 1 | # Tenant email: senders |
| 2 | |
| 3 | Design for amadan `carlos/platform` discussions **#147** (send as an |
| 4 | address under an SES identity CARLOS does not own) and **#148** |
| 5 | (tenant email per instance: domain, credential, cap and pause for each |
| 6 | customer of a multi-tenant app). |
| 7 | |
| 8 | Base: `origin/main` at 520fcc04. Author: Paul Campbell with Claude. |
| 9 | Date: 2026-09-15. Revised after two reviews: one against the code, one |
| 10 | against the AWS documentation. |
| 11 | |
| 12 | ## 1. The problem |
| 13 | |
| 14 | Tenant email today is keyed by (account, app) everywhere: `email.json`, |
| 15 | the IAM user `carlos-mail-<acct>-<app>`, the `DailyCap`, the pause, the |
| 16 | `mail_counts` rows, and the delivery of `CARLOS_SMTP_*` into the app's |
| 17 | default config bundle. Two requests do not fit that shape. |
| 18 | |
| 19 | **#147.** Jelly runs as an app in two accounts (`jam` and `stripe`) and |
| 20 | both must send as `jelly@tito.io`. `tito.io` is a verified SES identity |
| 21 | that Tito Classic has owned for years in the same AWS account. The |
| 22 | existing walk would adopt it and then rewrite it: `EnsureIdentity` |
| 23 | moves MAIL FROM to `mail-eu-west-1.tito.io` (`aws.go:292`), |
| 24 | `SetIdentityConfigSet` binds `carlos-<acct>` as the identity's default |
| 25 | configuration set (`walk.go:301`), the domain claim is first-come |
| 26 | (`domainclaim.go:119`), and `Teardown` calls `DeleteIdentity` |
| 27 | (`walk.go:1205`). |
| 28 | |
| 29 | **#148.** titogo runs one instance per customer under a single app. |
| 30 | Each customer needs their own domain, credential, cap and pause. Today |
| 31 | `EnsureUser` writes one policy listing every domain granted to the app |
| 32 | (`walk.go:634`), delivered to every instance through the app's default |
| 33 | bundle (`walk.go:818`), so code in one customer's instance can send as |
| 34 | every other customer's domain. |
| 35 | |
| 36 | ## 2. Decisions taken before this document |
| 37 | |
| 38 | 1. **Extend the IAM-per-sender model now, and iterate towards a |
| 39 | platform SMTP relay.** The relay is wanted anyway, as a layer over |
| 40 | SES that could front a different SMTP provider. §10 keeps that door |
| 41 | open rather than building it. |
| 42 | 2. **Per-instance mode.** Once an app has instance senders, instances no |
| 43 | longer receive the app's credential at all. |
| 44 | 3. **titogo's backend drives domain onboarding through the console |
| 45 | API**, with the CLI as a wrapper over the same doors. |
| 46 | 4. **For #147, an operator registers the external identity once and may |
| 47 | mark it open; accounts then claim addresses under it themselves.** |
| 48 | 5. **A central hub does the onboarding, holding one credential** (§6.1), |
| 49 | so no per-instance machine token is needed for this work. The hub's |
| 50 | credential carries a new narrow role, `svcrole.Mail`, rather than |
| 51 | Admin. |
| 52 | 6. **Instance backing is mixed today, and moving to the isolating one is |
| 53 | the intent.** So `enable` checks the route's backing rather than |
| 54 | assuming it (§6.4). |
| 55 | |
| 56 | ## 3. What the AWS documentation actually says |
| 57 | |
| 58 | The first draft of this design got three of these wrong, so they are |
| 59 | recorded here with their sources. |
| 60 | |
| 61 | - **Condition keys.** The v1 action set, which is what SMTP authorises |
| 62 | against, has `ses:ApiVersion`, `ses:FeedbackAddress`, |
| 63 | `ses:FromAddress`, `ses:FromDisplayName` and `ses:Recipients`. SES v2 |
| 64 | adds `ses:TenantName` and `ses:MultiRegionEndpointId`. **No condition |
| 65 | key names a configuration set.** The configuration set is a *resource* |
| 66 | on the send action, which constrains which set may be named but cannot |
| 67 | force one to be named — so a credential cannot be compelled to publish |
| 68 | events. |
| 69 | - **`ses:TenantName` is documented for `SendEmail` and `SendBulkEmail` |
| 70 | only.** The `SendRawEmail` row, which is what the SMTP interface |
| 71 | authorises against, does not list it, although an AWS blog publishes a |
| 72 | policy that uses it there. This is the first spike (§4). |
| 73 | - **There is no tenant-level default configuration set.** A send on |
| 74 | behalf of a tenant must either name a configuration set associated |
| 75 | with that tenant, or use an identity whose *default* set is associated |
| 76 | with it. So per-sender configuration sets on a shared identity are |
| 77 | impossible, and an address sender under a borrowed identity can only |
| 78 | publish events if the message names CARLOS's set. |
| 79 | - **Associating a resource with a tenant does not modify it**, and one |
| 80 | identity may belong to many tenants. It has one side effect on the |
| 81 | owner: an identity cannot be deleted while an association exists. |
| 82 | - **SES pauses tenants by itself.** Every new tenant gets the Standard |
| 83 | reputation policy, which auto-pauses on high-severity findings, and |
| 84 | Trust & Safety can pause one too. `SetKeyActive(true)` cannot lift |
| 85 | that. Sending quotas remain account-wide per region; the account |
| 86 | suppression list still applies unless a tenant opts into its own. |
| 87 | - **`ses:caller-identity` names the IAM user** on Send, Bounce, |
| 88 | Complaint, Delivery and Reject, but only for events published through |
| 89 | a configuration set, and not on Rendering Failure or DeliveryDelay. |
| 90 | |
| 91 | ## 4. Spike first |
| 92 | |
| 93 | Half a day against the Tito deployment's AWS account, on a throwaway |
| 94 | identity and tenant. It is blocking, because two of its answers change |
| 95 | the policy this design writes. |
| 96 | |
| 97 | 1. **Send over SMTP with `X-SES-TENANT` set, under a policy conditioned |
| 98 | on `ses:TenantName`.** Then send again without the header. Three |
| 99 | outcomes: both succeed (the key is not populated for SMTP, so the |
| 100 | condition buys nothing); tagged succeeds and untagged is refused (the |
| 101 | design works as written); both are refused (SES does not populate the |
| 102 | key at all and the condition denies everything). In outcome one and |
| 103 | three the tenant condition comes out of the policy, and isolation |
| 104 | rests on `ses:FromAddress` plus SES's own tenant-association |
| 105 | validation. |
| 106 | 2. **Send under a tenant without `X-SES-CONFIGURATION-SET`** on an |
| 107 | identity whose default set is not associated with that tenant, to |
| 108 | confirm it is refused, and with the header to confirm events arrive. |
| 109 | 3. **Associate an identity created outside CARLOS** and confirm its MAIL |
| 110 | FROM, DKIM and default configuration set are unchanged, and that |
| 111 | sends on it *without* a tenant still work — Classic's mail must not |
| 112 | change behaviour the moment CARLOS associates `tito.io`. |
| 113 | 4. **Pause a tenant** with `UpdateReputationEntityCustomerManagedStatus` |
| 114 | and confirm `GetTenant.SendingStatus` reflects it, so §8 can adopt it |
| 115 | as the pause verb. |
| 116 | |
| 117 | ### 4.1 Results, run 2026-09-15 |
| 118 | |
| 119 | Run in the Tito deployment's account (665106592142, `eu-west-1`) against |
| 120 | a throwaway identity `spike.teamtito.com` shaped like `tito.io`: DKIM |
| 121 | verified, no custom MAIL FROM, and a default configuration set |
| 122 | (`spike-other`) standing in for Classic's. Every resource was deleted |
| 123 | afterwards. |
| 124 | |
| 125 | **1. `ses:TenantName` is enforced over SMTP. Outcome two, the good one.** |
| 126 | A policy of `ses:SendRawEmail` on the identity and `spike-owned`, |
| 127 | conditioned `StringEquals ses:TenantName = spike-tenant`: |
| 128 | |
| 129 | | send | result | |
| 130 | |---|---| |
| 131 | | `X-SES-TENANT` + `X-SES-CONFIGURATION-SET` | accepted | |
| 132 | | no headers | 554, not authorized on the identity | |
| 133 | | configuration set header only | 554, not authorized on the identity | |
| 134 | |
| 135 | So the tenant condition stays in the policy, and a credential cannot |
| 136 | send without naming its tenant. That is real enforcement, not honour |
| 137 | system, and it is what makes one customer's credential useless for |
| 138 | another customer's mail. |
| 139 | |
| 140 | **2. There is no tenant-level default configuration set, as documented.** |
| 141 | A send with `X-SES-TENANT` but no configuration-set header was refused |
| 142 | with `554 ... not authorized to perform 'ses:SendRawEmail' on resource |
| 143 | '...configuration-set/spike-other'`. SES applied the *identity's* |
| 144 | default set, which belongs to the identity's owner. So an address sender |
| 145 | must set both headers, and the bundle carries `<P>_CONFIG_SET` and |
| 146 | `<P>_TENANT`. |
| 147 | |
| 148 | **3. Association is inert for the identity's owner, except for deletion.** |
| 149 | `GetEmailIdentity` before and after `CreateTenantResourceAssociation` |
| 150 | was byte-identical: DKIM tokens, `MailFromAttributes` and |
| 151 | `ConfigurationSetName` all unchanged. A second IAM user with no tenant |
| 152 | condition — Classic's shape — still sent on the identity with no tenant |
| 153 | header, and its events still published to the identity's own default |
| 154 | set, not CARLOS's. `DeleteEmailIdentity` was refused: *"Cannot delete |
| 155 | ... because it has tenant associations."* That is the cross-team |
| 156 | contract §7 records. |
| 157 | |
| 158 | **4. A tenant can be paused directly, and CARLOS can tell whose pause it |
| 159 | is.** `UpdateReputationEntityCustomerManagedStatus ... DISABLED` made |
| 160 | the next send fail with `554 Message rejected: Sending disabled for |
| 161 | <tenant arn>`. `GetTenant.SendingStatus` read `DISABLED`, and |
| 162 | `GetReputationEntity` returned `SendingStatusAggregate: DISABLED` with |
| 163 | `CustomerManagedStatus: {Status, Cause: "Status manually updated.", |
| 164 | LastUpdatedTimestamp}` and a separate `AwsSesManagedStatus` (null here). |
| 165 | Re-enabling restored sending. The tenant's policy at creation was |
| 166 | `arn:aws:ses:eu-west-1:aws:reputation-policy/standard`, which is the |
| 167 | auto-pausing one. |
| 168 | |
| 169 | So §8 adopts the tenant pause as the pause verb for any sender that has |
| 170 | a tenant, keeps `SetKeyActive` for those that do not, and reads the two |
| 171 | status fields apart so an SES-imposed pause is reported as SES's rather |
| 172 | than silently "resumed" by CARLOS on the next day roll. |
| 173 | |
| 174 | **Also confirmed:** published events carry |
| 175 | `ses:caller-identity: ["spike-tenant-user"]`, the bare IAM user name, |
| 176 | which is what §8's attribution reads. |
| 177 | |
| 178 | ### 4.2 Two prerequisites before any tenant-attributed send |
| 179 | |
| 180 | Found while building step 2, and both bite at roll time rather than build |
| 181 | time. |
| 182 | |
| 183 | 1. **Existing domains have no tenant association, and nothing will ever |
| 184 | create one.** Step 2 associates an identity and the account's |
| 185 | configuration set inside `EnsureDomain`, which is reached only from |
| 186 | `carlos email domains add`. `VerifyDomain` does not associate. So every |
| 187 | domain already in the estate keeps a verified identity with no |
| 188 | association, and the first send that carries `X-SES-TENANT` fails SES's |
| 189 | validation. Before step 3 or step 5 rolls, either a backfill pass |
| 190 | associates every recorded identity and configuration set, or |
| 191 | `VerifyDomain` learns to associate too. This is the failure the standing |
| 192 | warning in `aws.go` exists to prevent, so it is not optional. |
| 193 | 2. **The control-plane role needs two new IAM actions**, |
| 194 | `ses:CreateTenantResourceAssociation` and |
| 195 | `ses:DeleteTenantResourceAssociation`. That policy does not live in this |
| 196 | repo. If the grant is missing, `AccessDenied` is neither `AlreadyExists` |
| 197 | nor `NotFound`, so it is not swallowed: `domains add` fails after the |
| 198 | record and the DNS are already written, and `Teardown` returns before |
| 199 | `DeleteIdentity` and before releasing the global domain claim, which |
| 200 | leaves that claim stranded until the permission lands. Confirm the grant |
| 201 | before rolling. |
| 202 | |
| 203 | ## 5. The sender |
| 204 | |
| 205 | A **sender** owns a From address, a credential, a cap, a pause and a set |
| 206 | of counts. |
| 207 | |
| 208 | ```go |
| 209 | // internal/mail/sender.go |
| 210 | type Sender struct { |
| 211 | Schema int `json:"schema"` // 1 |
| 212 | ID string `json:"id"` // "app", or provision.InstanceID(host) |
| 213 | Slug string `json:"slug"` // AWS-safe name component, see below |
| 214 | Scope string `json:"scope"` // ScopeApp | ScopeInstance |
| 215 | Host string `json:"host,omitempty"` |
| 216 | Source string `json:"source"` // SourceDomain | SourceAddress |
| 217 | Declared *Declared `json:"declared,omitempty"` |
| 218 | Grant *GrantBlock `json:"grant,omitempty"` |
| 219 | Provisioned *Provisioned `json:"provisioned,omitempty"` |
| 220 | Standalone []*Standalone `json:"standalone,omitempty"` |
| 221 | Identity string `json:"identity,omitempty"` // external identity ARN |
| 222 | Address string `json:"address,omitempty"` // exact address, lowercased |
| 223 | TenantARN string `json:"tenant_arn,omitempty"` // from CreateTenant |
| 224 | PerInstance bool `json:"per_instance,omitempty"` // app sender only, §6.3 |
| 225 | } |
| 226 | ``` |
| 227 | |
| 228 | `Declared`, `GrantBlock`, `Provisioned` and `Standalone` keep their |
| 229 | current shapes and move inside the sender unchanged. |
| 230 | |
| 231 | **Records.** `apps/<acct>/<app>/email/senders/<id>.json`, one object per |
| 232 | sender, each with its own CAS etag so two instances never contend. |
| 233 | |
| 234 | **Names.** The first draft's names were invalid: SES caps tenant and |
| 235 | configuration-set names at 64 characters of `[A-Za-z0-9_-]`, IAM caps a |
| 236 | user name at 64, and an instance id is the host itself when it is 40 |
| 237 | characters or less, dots included — `carlos-mail-<acct>-<app>+i+<id>` |
| 238 | reaches 104 characters. So each sender gets a `Slug`, computed once at |
| 239 | creation and stored on the record: |
| 240 | |
| 241 | - app sender: `Slug = ""`, names unchanged |
| 242 | (`carlos-mail-<acct>-<app>`, tenant and set `carlos-<acct>`). |
| 243 | - instance sender: `Slug = <first 8 of base32(sha256(acct/app/id))>`, |
| 244 | giving IAM user `carlos-mail-<acct>-<app>+i+<slug>` and tenant |
| 245 | `carlos-<acct>-<app>-<slug>`, both bounded at 12 + 16 + 1 + 32 + 3 + 8 |
| 246 | = 72 — still over 64 for the longest account and app, so the app |
| 247 | component is itself truncated to 24 characters with the hash carrying |
| 248 | uniqueness. The exact rule lives in one function, `SenderNames`, with |
| 249 | a test that enumerates the bounds. |
| 250 | |
| 251 | `SenderNames` also covers standalone credentials, whose current name |
| 252 | (`carlos-mail-<acct>-<app>+s+<name>`, `policy.go:92`) already reaches 95 |
| 253 | characters against IAM's 64 with nothing checking it. The bounds test |
| 254 | enumerates every form. |
| 255 | |
| 256 | Because the slug is a hash, nothing parses it back, and a 40-bit slug |
| 257 | needs collision detection. Both are served by a reverse index at |
| 258 | `email/callers/<iamuser>.json` → (account, app, sender), with the same |
| 259 | rules the domain claim already uses (`domainclaim.go:124`): |
| 260 | |
| 261 | - Written **right after `EnsureUser`** (`walk.go:669`), which runs on |
| 262 | every enable, so a re-enable converges it. Writing it before `MintKey` |
| 263 | would strand a sender forever when a crash landed between the two. |
| 264 | - The Put is create-only; on conflict it re-reads. The same (account, |
| 265 | app, sender) is success — otherwise it is a slug collision, and the |
| 266 | slug is recomputed with a salt that is stored on the record. |
| 267 | - Deleted by `RevokeStandalone`, `Teardown` and the account reap. The |
| 268 | key sits outside `apps/<acct>/<app>/`, so `release.PurgeApp`'s prefix |
| 269 | sweep never reaches it; without an explicit delete, a stale entry |
| 270 | would attribute SES events to a purged app and insert counts for it. |
| 271 | - Only the slugged names need it. `SenderOfCaller` (§8) falls back to |
| 272 | parsing for `carlos-mail-<acct>-<app>` and its `+s+` standalones. |
| 273 | |
| 274 | **Configuration sets stay per account** (`carlos-<acct>`), because an |
| 275 | identity has only one default set and §3 rules out per-sender sets on a |
| 276 | shared identity. Each sender's tenant is associated with that set, and |
| 277 | the set's ARN stays in the sender's policy `Resource`. |
| 278 | |
| 279 | `SenderNames` covers standalone credentials too, but it never changes a |
| 280 | name that exists: `RefreshPolicies` recomputes `StandaloneUserName` |
| 281 | rather than reading `sa.IAMUser` (`walk.go:364`), so a changed rule |
| 282 | would write a policy for a new principal and leave the live one stale. |
| 283 | The rule is: existing shapes are unchanged, and a name that would exceed |
| 284 | the bound is refused at create. |
| 285 | |
| 286 | **Migration.** `mail.Get(acct, app)` prefers `senders/app.json` and |
| 287 | falls back to `email.json`. When it served the fallback it returns an |
| 288 | empty etag, so every writer's `Put` — and they all pass back the etag |
| 289 | they were given (`walk.go:522`, `:736`, `:909`, `:993`, `:1070`) — is a |
| 290 | create by construction, and a lost race surfaces as `ErrCASConflict` |
| 291 | exactly as it does today. `mail.Put` **always** writes |
| 292 | `senders/app.json`. `email.json` is left in place, unwritten, for one |
| 293 | release and then deleted by a sweep. A console rolled back after the |
| 294 | first sender write loses writes made in between — a pause or a rotation |
| 295 | would have to be redone — which is recorded here and in the roll notes. |
| 296 | |
| 297 | ## 6. #148: instance senders |
| 298 | |
| 299 | ### 6.1 The caller is a hub, not the customer instances |
| 300 | |
| 301 | titogo's own backend onboards every customer, so the instance email |
| 302 | doors are `Allow(svcrole.Admin)` — the same role the app-level email |
| 303 | doors carry today — and the hub holds one named service credential with |
| 304 | that role, bound to the titogo app. |
| 305 | |
| 306 | That credential must not be delivered into customer instances. Today |
| 307 | `handleAPIInstanceToken` mints a token bound to (account, app) and its |
| 308 | documented delivery path is `carlos secrets set -app <app>` |
| 309 | (`apiinstances.go:1367`), the bundle every instance renders, so anything |
| 310 | delivered that way reaches every customer. The hub's credential lives |
| 311 | wherever the hub runs — a different app, or an instance with its own |
| 312 | overlay once §6.2 exists. The console cannot check this: bundle values |
| 313 | are sealed and it holds no key (`apimail.go:483`), so where a credential |
| 314 | was delivered is not a decidable question. It is an operator |
| 315 | attestation, stated in the enable door's documentation, not a gate. |
| 316 | |
| 317 | **What an Admin-role service credential reaches.** `svcrole.Allow` adds |
| 318 | Admin to every non-empty set (`svcrole.go:120`), so a titogo-bound Admin |
| 319 | credential reaches every app-scoped door that names any role: ship, |
| 320 | promote and rollback, pipeline, ledgers, instances create/patch/delete/ |
| 321 | move, domains, logs and analytics, features, place, the store's |
| 322 | create/provision/grant/rotate, relay, and every email door except |
| 323 | credentials and pause. It cannot reach the person-only doors |
| 324 | (`svcrole.Set{}`): instance-token minting, the instances manifest, |
| 325 | channel, routes, service minting, fleets, store apply and approve, and |
| 326 | system. A compromised hub is therefore a compromised titogo deploy, not |
| 327 | only a compromised mailer. |
| 328 | |
| 329 | That inverts why those doors are Admin in the first place |
| 330 | (`apimail.go:460`): Admin was chosen to be *narrower* than Publish or |
| 331 | Instance, so that a deploy credential could not acquire a sending |
| 332 | identity. For a hub whose only job is onboarding, it means the opposite |
| 333 | — it must be able to deploy in order to onboard. |
| 334 | |
| 335 | **So this design adds one entry to the closed role catalogue, |
| 336 | `svcrole.Mail`**, and every email door — the app-level ones that exist |
| 337 | and the instance-level ones in §6.4 — becomes `Allow(svcrole.Mail)`. |
| 338 | Because `Allow` always adds Admin (`svcrole.go:120`), today's Admin |
| 339 | credentials keep reaching them, so nothing breaks; what changes is that |
| 340 | titogo's hub can hold a credential that onboards customer domains and |
| 341 | does nothing else. `svcroutes_test.go` pins the new role's door list. |
| 342 | |
| 343 | **Rate budget.** `mailWriteGates` uses the person-sized write limiter |
| 344 | keyed on the audit string (`apimail.go:136`), not `allowWrite` for |
| 345 | service principals (`svcprincipal.go:394`). Bulk onboarding through the |
| 346 | hub will 429 against a person's budget, so the mail write gate takes the |
| 347 | service budget when the principal is a service. |
| 348 | |
| 349 | Two consequences worth stating plainly. If customer code ever needs to |
| 350 | call these doors for itself, tokens bound to (account, app, instance) |
| 351 | become a prerequisite again, and this design is shaped so that is a |
| 352 | later change to the door's role set and nothing else. And separately, |
| 353 | the app-wide `CARLOS_INSTANCE_TOKEN` that customer instances already |
| 354 | hold reaches the existing instance create, patch and delete doors |
| 355 | (`authcli.go:612`, `:628`), so one customer's code can already delete a |
| 356 | sibling instance. That is not introduced here and not fixed here; it is |
| 357 | worth its own discussion. |
| 358 | |
| 359 | ### 6.2 Per-instance config delivery |
| 360 | |
| 361 | A third bundle layer, resolved after the named environment: |
| 362 | |
| 363 | ``` |
| 364 | apps/<acct>/<app>/config.json default |
| 365 | apps/<acct>/<app>/environments/<name>.json environment |
| 366 | apps/<acct>/<app>/instances-config/<host>.json instance ← new |
| 367 | ``` |
| 368 | |
| 369 | - Same `Manifest` shape, same sealed envelopes, same name and value |
| 370 | rules. `ExecDelivery` is **not** read from this layer, matching the |
| 371 | ruling at `appconfig.go:61-69`. |
| 372 | - `LoadMerged` keeps its signature; `LoadMergedHost(ctx, s, acct, app, |
| 373 | env, host)` adds the third overlay, and `Sync`/`SyncExec` call it per |
| 374 | route after resolving the group base, so a pass costs one extra bucket |
| 375 | read per instance that has an overlay and none for instances that do |
| 376 | not. |
| 377 | - Writing the layer bumps the app's generation object, as |
| 378 | `SetExecDeliveryAt` does, so the fast loop converges. |
| 379 | - Guards that assume a two-layer merge and must be revisited: |
| 380 | `lostKeys`/`defaultMissing` (`sync.go:260`, `:388`), the mass-removal |
| 381 | guard (`syncexec.go:351`), `AwaitingRestart` (`syncexec.go:712`) and |
| 382 | `allShadowed` (`syncexec.go:813`). `ConfigRouteFingerprints` |
| 383 | (`routefingerprint.go:58`) digests registry columns only and cannot |
| 384 | see a bucket-side change; the generation bump is that layer's signal. |
| 385 | - Only the platform writes this layer: no CLI verb, no console door. It |
| 386 | exists so a credential can be scoped to one instance. |
| 387 | |
| 388 | ### 6.3 Entering per-instance mode |
| 389 | |
| 390 | A prefix listing cannot decide this without racing an app-sender enable, |
| 391 | so the app sender carries `PerInstance bool`, and the transition is one |
| 392 | CAS write on `senders/app.json` that sets `PerInstance` together with |
| 393 | `Active = false` and `PauseKind = PauseKindPerInstance`, then: |
| 394 | |
| 395 | 1. **`ReapKeys(user, nil)` on the app sender's IAM user and on every |
| 396 | standalone user** — list-and-delete, not `SetKeyActive(false)` on the |
| 397 | recorded ids. Deactivating recorded ids is not enough, because |
| 398 | `Enable` mints and delivers a key *before* its own CAS `Put` |
| 399 | (`walk.go:717-738`): an enable racing the transition leaves an |
| 400 | active, unrecorded key in every instance's bundle that nothing would |
| 401 | ever reap. Deleting whatever keys exist makes the racing delivery |
| 402 | inert. Instance `Enable` re-runs the same reap. |
| 403 | 2. Unsets the app sender's `CARLOS_SMTP_*` vars from the default bundle. |
| 404 | |
| 405 | `PauseKindPerInstance` matters because `resumeStaleCapPause` |
| 406 | (`mailcounts.go:160`) runs on the status read and reactivates *every* |
| 407 | key including standalones (`walk.go:973`); a cap-paused app sender would |
| 408 | otherwise re-arm the credential every instance still holds on the next |
| 409 | UTC day roll. `SetPaused(false)` and `resumeStaleCapPause` both refuse |
| 410 | while `PerInstance` is set. |
| 411 | |
| 412 | App-sender `Enable` refuses when `PerInstance` is set, under that same |
| 413 | etag. Instance `Enable` requires it. Leaving per-instance mode, once the |
| 414 | last instance sender is gone, is one CAS that clears `PerInstance`, sets |
| 415 | `Active = true` and clears `PauseKind`, followed by a normal enable — |
| 416 | otherwise `Enable` carries the false `Active` forward (`walk.go:698`) |
| 417 | and mints a dead key. |
| 418 | |
| 419 | `grantedDomains` filters on the domain record's new `Senders` list, not |
| 420 | `Apps`, and an instance sender's claim never adds the app to `Apps`. So |
| 421 | the app sender's policy can never name a customer domain. |
| 422 | |
| 423 | Everything that walks `rec.Apps` must walk `Senders` instead, or an |
| 424 | instance sender's policy is never refreshed when its configuration set |
| 425 | is rebound — which is 554 on every send, the failure #271 and #279 |
| 426 | already cost us. That means `RefreshPolicies` (`walk.go:324`, called |
| 427 | from `EnsureDomain:297` and `VerifyDomain:461`) and `mailAppDomains` |
| 428 | (`apimail.go:299`), which the enable door and status both read. |
| 429 | |
| 430 | There is also a door for the transition itself: the first instance |
| 431 | `enable` performs it, and `DELETE .../instances/{host}/email` on the |
| 432 | last instance sender leaves per-instance mode. Nothing else sets the |
| 433 | flag. |
| 434 | |
| 435 | ### 6.4 The doors, and what they check |
| 436 | |
| 437 | ``` |
| 438 | POST /api/cli/apps/{account}/{app}/instances/{host}/email declare |
| 439 | POST .../instances/{host}/email/domains attach |
| 440 | POST .../instances/{host}/email/domains/{domain}/verify |
| 441 | POST .../instances/{host}/email/enable |
| 442 | POST .../instances/{host}/email/rotate |
| 443 | GET .../instances/{host}/email status |
| 444 | DELETE .../instances/{host}/email teardown |
| 445 | ``` |
| 446 | |
| 447 | All are `Allow(svcrole.Mail)`, like the app-level email doors after |
| 448 | §6.1, so the `Instance` role pin is untouched. Pause, |
| 449 | resume and cap changes stay operator-only through the existing |
| 450 | `POST …/email/pause`, which gains an `instance` field. |
| 451 | |
| 452 | Two checks at `enable`, both refusals with an explaining message: |
| 453 | |
| 454 | - **A uid of its own.** The predicate is not "unit-backed" — a host_role |
| 455 | app runs its units as `User=carlos-<app>` with `DynamicUser=no` |
| 456 | (`caps/render.go:101-112`), one uid per *app*, and `ProtectProc` |
| 457 | hides other users' processes, not a sibling's at the same uid. The |
| 458 | check is that this instance's process gets a uid no sibling shares: |
| 459 | `DynamicUser=yes` with no host_role grant, or an exec route under an |
| 460 | isolated-exec layout with a per-instance uid. Plain exec routes share |
| 461 | the agent's uid and `<dataDir>/<host>.config.env` is readable by every |
| 462 | sibling (`internal/activator/admintoken.go:6-12`), so a per-instance |
| 463 | credential buys nothing there. This is the dependency on #126. |
| 464 | - **Delivery.** The instance's box must be running an agent that renders |
| 465 | the instance layer (§6.5). |
| 466 | |
| 467 | **Domain attach.** `claimDomain` gains a `Sender` field and refuses a |
| 468 | re-claim whose (app, sender) differs from the holder's, *including |
| 469 | within the same account* — today a same-account re-claim is a silent |
| 470 | no-op (`domainclaim.go:137`), which would let one instance attach |
| 471 | another's verified domain. `EnsureDomain` refuses a domain that has an |
| 472 | external-identity record before it claims anything, and the orphan-claim |
| 473 | sweep in `Teardown` matches on sender and skips external domains, so a |
| 474 | refused `EnsureIdentity` can never wedge an app's teardown. |
| 475 | |
| 476 | ### 6.5 Confirming delivery |
| 477 | |
| 478 | `ConfigAck` gains `Schema int` and `InstanceConfigETag string`: the etag |
| 479 | of the instance overlay the agent rendered. The console compares it with |
| 480 | the etag its own `Put` returned, which is decidable — unlike a key-id |
| 481 | map, which cannot distinguish an old agent from a new agent rendering a |
| 482 | route with no mail vars. |
| 483 | |
| 484 | An ack is written only when a pass changes a file (`configack.go:66`), |
| 485 | so a fresh instance, or one whose config has not moved since its agent |
| 486 | was rolled, has no ack at all — absence cannot mean "old agent". |
| 487 | |
| 488 | **The capability question is answered by the box record's agent version, |
| 489 | not by the ack.** An earlier draft had the agent rewrite the ack on every |
| 490 | pass so that absence became decidable. That is the wrong trade: it is a |
| 491 | Get and a Put per converged route per pass on every box in the fleet, |
| 492 | where today it is one write per changed file, and on a fleet-door box it |
| 493 | is worse than useless — `control/config-ack/` is outside the door's read |
| 494 | and write scopes (`edge/storedoor.go:389`, `:489`), so every one of those |
| 495 | passes would fail, log, and still leave the console with nothing. It also |
| 496 | forces the ack's `At` to stop describing the delivery it sits beside. |
| 497 | |
| 498 | So acks stay change-only. The enable-time refusal reads the agent version |
| 499 | and names the box and the version. Delivery is still decidable where it |
| 500 | matters, because writing an instance's credential always changes that |
| 501 | instance's file, so it always produces an ack carrying the |
| 502 | `InstanceConfigETag` to compare. A missing ack reads as "not yet |
| 503 | delivered", never as "old agent". `carlos email status --instance <host>` |
| 504 | reports delivered, stale or not yet delivered. |
| 505 | |
| 506 | **A fleet-door box cannot confirm delivery at all**, because it cannot |
| 507 | write the ack. Until that door grants `control/config-ack/<box>/`, an |
| 508 | instance sender on such a box can be enabled but its delivery will read |
| 509 | as unconfirmed. The roll note says so, and the door grant is its own |
| 510 | piece of work. |
| 511 | |
| 512 | ## 7. #147: a sender under an identity CARLOS does not own |
| 513 | |
| 514 | **The external identity** is registered once per deployment by an |
| 515 | operator at `email/external-identities/<domain>.json`: domain, region, |
| 516 | identity ARN, `Open bool`, who registered it and when. The region must |
| 517 | match the deployment's, because tenants and identities are per region. |
| 518 | |
| 519 | CARLOS never calls `CreateEmailIdentity`, |
| 520 | `PutEmailIdentityMailFromAttributes` (both inside `EnsureIdentity`), |
| 521 | `SetIdentityConfigSet` or `DeleteIdentity` for a registered identity. A |
| 522 | provisioner wrapper refuses those verbs for any domain with such a |
| 523 | record, and a test pins the refusal. `EnsureDomain` refuses the domain |
| 524 | before claiming it (§6.4), so the refusal can never strand a claim. |
| 525 | |
| 526 | **Enable takes an address branch.** Today `Enable` resolves granted |
| 527 | domains and refuses with `ErrNoDomains` when there are none |
| 528 | (`walk.go:627`), then insists the From address sits at a granted domain |
| 529 | name (`:647`), and `SendPolicyJSON` requires each identity name to be a |
| 530 | granted domain or a parent of one (`policy.go:167`). An external |
| 531 | identity has no `DomainRecord` in the account by design, so an address |
| 532 | sender needs its own branch in both: the identity ARN comes from the |
| 533 | external-identity record, the From address from `Sender.Address`, and |
| 534 | the policy renderer gets an address form whose identity check is that |
| 535 | the address's domain is the identity or a child of it. |
| 536 | |
| 537 | **Verification.** `Status.Verified()` requires `MailFrom == "SUCCESS"` |
| 538 | (`provisioner.go:45`), which an identity with no custom MAIL FROM never |
| 539 | reaches. External identities are checked with a relaxed predicate: DKIM |
| 540 | and sending enabled, MAIL FROM ignored. |
| 541 | |
| 542 | **Address claims.** `email/address-claims/<address>/<acct>.json`, where |
| 543 | the address is lowercased and validated to the same grammar `Declared` |
| 544 | already enforces. The claim is **not** exclusive: `jam` and `stripe` may |
| 545 | both hold `jelly@tito.io`. It names the account, app, sender and who |
| 546 | claimed it, and `ListAddressClaims` gives the operator the roster. |
| 547 | Claiming requires `Open == true`; otherwise an operator writes the same |
| 548 | record as a grant. The policy condition is |
| 549 | `StringEqualsIgnoreCase ses:FromAddress`, since SES's `StringEquals` is |
| 550 | case-sensitive and a From address is not. |
| 551 | |
| 552 | **An address sender's address is fixed once claimed.** Changing it means |
| 553 | tearing the sender down and claiming again, and two doors refuse the |
| 554 | shortcut: the claim door when the sender already holds a different |
| 555 | address, and `Declare` when a From address would disagree with the |
| 556 | claimed one. Without both, `carlos email enable --from other@tito.io` on |
| 557 | a live sender renders a policy for the new address, strands the old |
| 558 | claim — which blocks every sibling app in the account and holds the |
| 559 | identity association open — and leaves `_FROM` and the policy naming |
| 560 | different addresses while status still reads healthy. |
| 561 | |
| 562 | **Counting an address sender depends on the app.** Because there is no |
| 563 | tenant-level default set (§3) and CARLOS may not touch `tito.io`'s |
| 564 | default, events only reach CARLOS when the message names CARLOS's |
| 565 | configuration set. So the sender's env bundle gains |
| 566 | `<P>_CONFIG_SET`, documented as a header the app must set, and |
| 567 | `carlos email status` says plainly when a sender has sent nothing — |
| 568 | the honour-system limitation you accepted, and one the relay removes. |
| 569 | The bundle also carries `<P>_TENANT`, because the spike (§4.1) kept the |
| 570 | `ses:TenantName` condition in the policy and a send that omits it is |
| 571 | refused outright. That refusal is the saving grace of the honour system: |
| 572 | a message missing either header does not send silently uncounted, it |
| 573 | does not send at all. |
| 574 | Enable also reads the external identity's default configuration set: if |
| 575 | it has one that is not associated with the sender's tenant, a |
| 576 | tenant-tagged send is refused outright, so enable refuses first, with |
| 577 | that explanation. |
| 578 | |
| 579 | **Doors.** Registration is operator-only, the claim is a member's, and |
| 580 | both are new routes: |
| 581 | |
| 582 | ``` |
| 583 | PUT /api/cli/email/external-identities/{domain} register (operator) |
| 584 | DELETE /api/cli/email/external-identities/{domain} deregister (operator) |
| 585 | GET /api/cli/email/external-identities list (operator) |
| 586 | GET /api/cli/email/external-identities/{domain}/claims roster (operator) |
| 587 | POST /api/cli/apps/{account}/{app}/email/address claim + declare (Admin) |
| 588 | ``` |
| 589 | |
| 590 | The claim door takes `{address}` and is what sets |
| 591 | `Sender.Source = SourceAddress`, `Sender.Address` and |
| 592 | `Sender.Identity`; it is `Declare` for an address sender, so declaring a |
| 593 | From address and claiming it are one call and cannot disagree. Enable, |
| 594 | rotate, status and teardown are the existing app-sender doors, which |
| 595 | read `Source` off the record. CLI: `carlos email external-identity |
| 596 | register|list|claims`, and `carlos email enable --from jelly@tito.io` |
| 597 | calls the claim door first when the domain resolves to an external |
| 598 | identity. |
| 599 | |
| 600 | **Refusal scope.** `EnsureDomain` refuses a domain that is a registered |
| 601 | external identity **or any subdomain of one**: a claim on |
| 602 | `mail.tito.io` would otherwise let CARLOS create and later delete an |
| 603 | identity underneath one it does not own. |
| 604 | |
| 605 | **Provisioner verbs.** The interface (`provisioner.go:49`) gains four, |
| 606 | each idempotent in the usual way, with `MemProvisioner` equivalents: |
| 607 | |
| 608 | ```go |
| 609 | AssociateTenantResource(ctx, tenant, resourceARN string) error |
| 610 | DisassociateTenantResource(ctx, tenant, resourceARN string) error |
| 611 | TenantStatus(ctx, tenant string) (TenantStatus, error) // GetTenant |
| 612 | SetTenantSending(ctx, tenantARN string, enabled bool) error // §8's pause |
| 613 | ``` |
| 614 | |
| 615 | plus `IdentityStatus` returning the identity's bound configuration set, |
| 616 | which it already carries (`Status.ConfigSet`), so enable can refuse an |
| 617 | external identity whose default set is not associated with the tenant. |
| 618 | |
| 619 | **Associations are wired in step 2, not here.** Today `EnsureTenant` |
| 620 | creates the container and nothing associates anything, and `aws.go:171` |
| 621 | warns that no send may carry `X-SES-TENANT` until associations exist. |
| 622 | Step 2 associates each account's existing tenant with its identities and |
| 623 | its configuration set, which changes no behaviour on its own, so that |
| 624 | step 3 can rely on them. |
| 625 | |
| 626 | **The cross-team contract.** While CARLOS's tenant association exists, |
| 627 | Classic cannot delete the `tito.io` identity — `DeleteEmailIdentity` |
| 628 | fails until the association goes. The registration step says so, and |
| 629 | teardown removes the association. |
| 630 | |
| 631 | **The trade-off you accepted.** With `Open`, any account in the |
| 632 | deployment can claim any address under `tito.io`, including |
| 633 | `billing@tito.io`. The cheap mitigation if it bites is an optional |
| 634 | `Local []string` allowlist on the external-identity record; it is not |
| 635 | built now. |
| 636 | |
| 637 | ## 8. Counting, caps and pauses per sender |
| 638 | |
| 639 | **Attribution by caller.** Every sender has its own IAM user, and SES |
| 640 | stamps `mail.tags["ses:caller-identity"]` with the user name on Send, |
| 641 | Bounce, Complaint, Delivery and Reject. `SenderOfCaller(caller)` |
| 642 | resolves it to (account, app, sender) in two steps: parse first, exactly |
| 643 | as `AppOfCaller` does today (`policy.go:42`), which answers for |
| 644 | `carlos-mail-<acct>-<app>` and its `+s+` standalones with sender |
| 645 | `"app"` and costs nothing; then, only for a slugged `+i+` name, one Get |
| 646 | on `email/callers/<iamuser>.json`. That ordering matters on the roll: |
| 647 | every app enabled today short-circuits `Enable` before `MintKey` |
| 648 | (`walk.go:677`), so it has no index entry until it next rotates, and a |
| 649 | Get-only resolver would silently stop writing the `by_sender` log rows |
| 650 | that `sesevents.go:498` writes now. |
| 651 | |
| 652 | `recordAndEnforceMail` counts against the resolved sender once, and |
| 653 | falls back to today's domain-wide loop only when the caller is unknown, |
| 654 | skipping any app in per-instance mode so its events are never charged to |
| 655 | the app sender. |
| 656 | |
| 657 | **This is a visible change, and it belongs in the roll note.** Today |
| 658 | every app on a shared domain is charged for every event on that domain |
| 659 | (`sesevents.go:509`); afterwards only the sending app is. A sibling's |
| 660 | cap stops being consumed by another app's sends, which is the intent, |
| 661 | but a tenant watching its own numbers will see them move. |
| 662 | |
| 663 | **Counts.** A new table, because SQLite cannot widen a primary key: |
| 664 | |
| 665 | ```sql |
| 666 | CREATE TABLE IF NOT EXISTS mail_sender_counts ( |
| 667 | account TEXT NOT NULL, app TEXT NOT NULL, sender TEXT NOT NULL, |
| 668 | day TEXT NOT NULL, sends INTEGER NOT NULL DEFAULT 0, |
| 669 | bounces INTEGER NOT NULL DEFAULT 0, complaints INTEGER NOT NULL DEFAULT 0, |
| 670 | rejects INTEGER NOT NULL DEFAULT 0, deliveries INTEGER NOT NULL DEFAULT 0, |
| 671 | PRIMARY KEY (account, app, sender, day)); |
| 672 | ``` |
| 673 | |
| 674 | Backfilled once from `mail_counts` with `sender = 'app'`; `mail_counts` |
| 675 | stays, unwritten, and is dropped a release later by a sweep that runs |
| 676 | once at console start. The rename, transfer, purge and account-reap |
| 677 | sweeps (`carryMailApp`, `forgetMailApp`, `forgetMailAccount`) all take |
| 678 | the new table, but it cannot join `mailAppTables` blindly because those |
| 679 | sweeps key on a timestamp column (`mailAppTimeColumn`, `mailevents.go:170`) |
| 680 | and this table has a `day` string instead — so it gets an explicit case |
| 681 | comparing `day` against the date. That also closes today's gap, where |
| 682 | `mail_counts` rows survive a rename. |
| 683 | |
| 684 | **Caps and pauses.** `TierCap` and the thresholds are unchanged and now |
| 685 | apply per sender. A pause calls `SetKeyActive(false)` on that sender's |
| 686 | keys alone, so one customer's bounce rate pauses that customer. |
| 687 | `resumeStaleCapPause` keeps its rule: cap pauses lift on the UTC day |
| 688 | roll, rate and manual pauses never do. |
| 689 | |
| 690 | **The pause verb is the tenant's.** §4.1 confirmed it, so a sender with |
| 691 | a tenant is paused with |
| 692 | `UpdateReputationEntityCustomerManagedStatus(RESOURCE, <tenant ARN>, |
| 693 | DISABLED)` and `SetKeyActive` stays the fallback for senders that have |
| 694 | none. That is a better pause than deactivating a key: it takes effect on |
| 695 | the next send rather than waiting for a credential refresh, and it |
| 696 | leaves the credential intact so resuming is one call. |
| 697 | |
| 698 | `EnsureTenant` stores `TenantArn`. Status reads `GetReputationEntity` |
| 699 | and keeps `CustomerManagedStatus` and `AwsSesManagedStatus` apart, so an |
| 700 | SES-imposed pause is reported as SES's and is never lifted by |
| 701 | `resumeStaleCapPause` on the day roll — a tenant SES disabled for |
| 702 | reputation must not be resumed by a cap timer. |
| 703 | |
| 704 | **Status.** `carlos email status --app <app>` prints the app sender as |
| 705 | today plus a line per instance sender; `--instance <host>` prints one. |
| 706 | The API reply gains a `senders` array while its existing top-level |
| 707 | fields keep describing the app sender, so an older CLI still works. |
| 708 | |
| 709 | ## 9. Testing |
| 710 | |
| 711 | - `MemProvisioner` grows tenants with associations and reputation |
| 712 | status, and refuses the forbidden verbs for a registered external |
| 713 | identity; a test pins that refusal and one pins that `EnsureDomain` |
| 714 | refuses before claiming. |
| 715 | - Walk tests per sender kind: address enable and teardown never call the |
| 716 | forbidden verbs; instance enable writes only the instance layer; |
| 717 | entering per-instance mode deactivates the app key and every |
| 718 | standalone, and unsets the vars in the same pass. |
| 719 | - Race test: an app enable that mints and delivers before its CAS `Put`, |
| 720 | interleaved with the per-instance transition, leaves no usable app |
| 721 | credential — the reap deleted the key even though the vars landed. |
| 722 | - A cap-paused app sender that enters per-instance mode is not |
| 723 | reactivated by the next UTC day roll. |
| 724 | - Name tests enumerating the IAM and SES bounds, including the longest |
| 725 | legal account and app. |
| 726 | - Policy tests: address condition (case-insensitive), tenant condition |
| 727 | present and absent, and the existing refusals. |
| 728 | - Attribution tests: caller names an instance sender; per-instance mode |
| 729 | never charges the app sender; unknown caller keeps today's behaviour; |
| 730 | an event type with no caller tag. |
| 731 | - Door tests: an app-scoped `Instance`-role token is refused at every |
| 732 | instance email door, and a stranger's 404 is byte-identical to the |
| 733 | refusal for an instance that exists in another account. |
| 734 | - Config tests: three-layer precedence, an old agent ignoring the layer, |
| 735 | the revisited `lostKeys` and mass-removal guards, and the ack carrying |
| 736 | the overlay etag. |
| 737 | - Door tests in `svcroutes_test.go`, the `Instance` role pin exemption, |
| 738 | and the backing and delivery refusals at enable. |
| 739 | |
| 740 | ## 10. What this leaves for the relay |
| 741 | |
| 742 | The relay becomes a third `Source` on the same sender: `SourceRelay`. |
| 743 | The app still reads `<P>_HOST`, `<P>_PORT`, `<P>_USER`, `<P>_PASS` and |
| 744 | `<P>_FROM`, so moving a sender onto it is a credential swap and a host |
| 745 | change, invisible to the app's code. It enforces the cap before the send |
| 746 | rather than after, removes the `<P>_CONFIG_SET` honour system for |
| 747 | address senders, spends one IAM user for the fleet rather than one per |
| 748 | sender, and is where a non-SES SMTP provider attaches. |
| 749 | |
| 750 | ## 11. Order of work |
| 751 | |
| 752 | 1. **Spike** (§4). Blocking; changes the policy and the pause verb. |
| 753 | 2. **Senders** (§5): the record, the migration, the naming, per-sender |
| 754 | counts, caps, pauses and status. No new capability, and it lands |
| 755 | `mail_sender_counts` and attribution by caller. |
| 756 | 3. **#147** (§7): external identities, address claims, the provisioner |
| 757 | wrapper, the address branch through enable and the policy renderer, |
| 758 | `<P>_CONFIG_SET`. Unblocks Jelly and depends only on step 2. |
| 759 | 4. **Per-instance config layer and acks** (§6.2, §6.5). |
| 760 | 5. **#148** (§6.3, §6.4): instance senders, per-instance mode, the |
| 761 | doors, the uid check. |
| 762 | 6. **Per-instance machine tokens**, after #148 rather than before it. |
| 763 | Not needed while a hub onboards, but the app-wide |
| 764 | `CARLOS_INSTANCE_TOKEN` every customer instance holds already reaches |
| 765 | the instance create, patch and delete doors (`authcli.go:612`, |
| 766 | `:628`), so one customer's code can delete another's instance today. |
| 767 | That is filed separately; this is where the fix lands. |
| 768 | |
| 769 | ## 12. Quotas and costs to watch |
| 770 | |
| 771 | - IAM users: 5,000 per AWS account, **not adjustable**. One per instance |
| 772 | sender plus one per standalone credential. At titogo's scale this is |
| 773 | the ceiling that eventually forces the relay. |
| 774 | - IAM inline policy: 2,048 characters per user, all policies aggregated. |
| 775 | This bites sooner than it looks: every granted domain contributes the |
| 776 | *same* account configuration-set ARN and nothing dedupes it |
| 777 | (`walk.go:634`, `:343`, `policy.go:205`), so a twelve-domain app is |
| 778 | already over. `slices.Compact` after the sort in the three callers |
| 779 | takes that to about twenty, and `SendPolicyJSON` refuses with the |
| 780 | count rather than letting IAM reject the policy. |
| 781 | - Configuration sets: 10,000 per region, not adjustable. Per account |
| 782 | here, so not a constraint. |
| 783 | - SES tenants: 10,000 per region, adjustable. Associations per tenant |
| 784 | are undocumented — ask AWS before relying on many. |
| 785 | - The SES control plane is throttled to one request per second for |
| 786 | everything except sends, so an enable that creates a tenant, two |
| 787 | associations and IAM resources needs backoff, and bulk onboarding is |
| 788 | serial. |
| 789 | - SES charges per tenant per month on top of per-message pricing. |
| 790 | |
| 791 | ## 13. Out of scope |
| 792 | |
| 793 | - The relay itself. |
| 794 | - Per-instance sending domains under the platform suffix; an instance |
| 795 | sender needs a custom domain or a registered address. |
| 796 | - Inbound email (#138), event push per sender, per-sender billing. |
| 797 | - `Local` allowlists on an external identity (§7). |
| 798 | |