From 0fbf6b7d4774a2d818151f13cca6c87668b070f4 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 18 Sep 2026 08:16:26 -0700 Subject: [PATCH] Masked email feature spec --- features/masked-email.md | 262 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 features/masked-email.md diff --git a/features/masked-email.md b/features/masked-email.md new file mode 100644 index 0000000..c32f32c --- /dev/null +++ b/features/masked-email.md @@ -0,0 +1,262 @@ +# Feature spec: masked email + +Status: draft, 2026-09-18. Feature 2 in SPEC.md §4. + +## Provenance + +Written for the clean room (SPEC.md §3). Sources, and nothing else: + +| Source | License | Used for | +|---|---|---| +| Stalwart's registry schema, `x:MaskedEmail` and `x:Email.maxMaskedAddresses`, upstream `d9dee0a` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | The stored record, field meanings, permissions | +| Stalwart documentation: "Masked email" (`email/management/masked-email.md`) and the MaskedEmail object reference | Unlicensed public documentation: facts used, prose not copied | Lifecycle, domain choice, quota, permissions, API | +| Fastmail's Masked Email API (`https://www.fastmail.com/for-developers/masked-email/`) | Published vendor API: facts used, prose not copied | The second API, its states and rules | +| RFC 8620 | IETF | `/get` and `/set` semantics, `SetError` types | +| Observation of INBUXA's live server | Observation | Everything under "To observe" once settled | + +No Enterprise-only file or snippet was used. As with multi-tenancy, the +drafting session writes specs only, and gaps are settled by observation or +marked **Decision**. None is filled from memory of upstream code. + +## What it is + +A masked address is a disposable address that delivers to one account +without revealing the account's real address. A user hands a different one +to each service, and can cut any of them off without touching the others. +Password managers can create them automatically when a new login is saved. + +Upstream ships this only in its Enterprise Edition. inbuxa-server ships it to +everybody, and serves it through two APIs at once: upstream's, so existing +clients keep working, and Fastmail's published one, so password managers that +already speak it work without changes. + +## The key fact for compatibility + +Every stored mask carries its full address in `email`. Delivery therefore +finds a mask by looking the recipient address up among stored masks, and +never by working out who owns an address from its shape. That is how every +address INBUXA users already hold keeps delivering: inbuxa-server doesn't need +to know how upstream built them. + +## Data model + +### Upstream's record, `x:MaskedEmail`, unchanged + +| Field | Upstream mutability | Meaning | +|---|---|---| +| `accountId` | immutable | The owning account | +| `email` | server-set | The address | +| `enabled` | mutable, default true | Upstream: whether mail is accepted | +| `description` | mutable | A short note from the user | +| `forDomain` | mutable | The origin of the site it was made for, e.g. `https://example.com` | +| `url` | mutable | A deep link back into the integrator's own record | +| `createdAt` | server-set | When it was made | +| `createdBy` | mutable | The creating client's name | +| `expiresAt` | immutable (create-only) | When it stops working. Upstream's docs say its generated addresses carry the expiry in the address itself | +| `emailPrefix` | create-only | Requested start of the local part: ≤ 64 chars, `a-z`, `0-9`, `_` | +| `emailDomain` | create-only | Requested domain | + +Permissions: `sysMaskedEmailGet`, `sysMaskedEmailQuery`, +`sysMaskedEmailCreate`, `sysMaskedEmailUpdate`, `sysMaskedEmailDestroy`. +Limit: `maxMaskedAddresses`, as the server default on `x:Email` and per +account in `quotas`. Unset means unlimited, and 0 turns the feature off for +that account. + +### Added by inbuxa-server, in its own store + +Kept beside the upstream record, keyed by mask id, so the upstream record +stays byte-for-byte what upstream wrote: + +- `state`: `pending`, `enabled`, `disabled` or `deleted` (see the next + section). Missing for masks created before the fork: derived from + `enabled`. +- `lastMessageAt`: when mail last arrived through it, or null. +- `pendingUntil`: for `pending` masks, when they're removed if no mail + arrives. +- **Tombstones**: every address ever issued, including destroyed ones, so an + address is never issued twice. + +## One state, two APIs + +Fastmail and upstream disagree about what "disabled" means. inbuxa-server +keeps one state per mask, and each API shows it in its own terms. + +| inbuxa-server state | Mail to it | Fastmail `state` | Upstream `enabled` | +|---|---|---|---| +| `pending` | Delivered, and the mask becomes `enabled` | `pending` | `true` | +| `enabled` | Delivered normally | `enabled` | `true` | +| `disabled` | Accepted, filed straight to Trash | `disabled` | `true` | +| `deleted` | Refused | `deleted` | `false` | +| (destroyed) | Refused, as an unknown address | not returned | not returned | +| (expired) | Refused | `deleted` | `false` | + +Writes map back: + +- **ME-1.** Fastmail `state` sets the state directly. `pending` can't be + set once the mask has left it, per Fastmail's rules. +- **ME-2.** Upstream `enabled: false` sets `deleted`. Upstream + `enabled: true` sets `enabled`, from any state. +- **ME-3.** Upstream `destroy`, and Fastmail `destroy`, remove the mask. The + address is kept as a tombstone and mail to it is refused like mail to any + unknown address. + +**Decision:** upstream's `enabled: false` means "reject" in its docs, which is +Fastmail's `deleted`, not its `disabled`. So `disabled` (to Trash) has no +upstream equivalent, and a mask in it reads as `enabled: true` there: mail is +accepted, which is the fact `enabled` reports. + +## Required behavior + +### Delivery + +- **ME-4.** A message to a masked address in `pending`, `enabled` or + `disabled` is delivered to the owning account, found by stored address. + Spam filtering, the account's Sieve scripts, quotas and tenant limits all + apply exactly as they would to mail for the account's own address. +- **ME-5.** `disabled` delivers into the account's Trash mailbox, skipping + the user's filing rules but not spam checks. +- **ME-6.** `deleted`, expired and destroyed masks refuse the message at + `RCPT TO`. The reply code matches upstream's reply for a disabled mask (to + observe, 1), so senders see the same result whichever server they meet. +- **ME-7.** Arriving mail sets `lastMessageAt`, and moves a `pending` mask to + `enabled`. +- **ME-8.** A `pending` mask with no mail within 24 hours of creation is + removed, per Fastmail's rule, and tombstoned. It's hidden from ihasmail's + list while pending, as Fastmail's own interface hides it. +- **ME-9.** Delivered mail keeps the masked address visible to the user: in + the `To` or `Cc` header as sent, and in a `Delivered-To` header naming the + mask, so filters and the reader can tell which mask it came through (to + observe, 3: match upstream if it already does this). +- **ME-10.** Sub-addressing (`mask+tag@domain`) on a mask works exactly as it + does on the account's own addresses (to observe, 4). + +### Sending + +- **ME-11.** A user may send from any of their masks in `pending`, `enabled` + or `disabled` state, as they can from their own aliases. Replying to mail + that came through a mask should default to sending from that mask: + ihasmail's job (see "ihasmail"), made possible by ME-9. Whether upstream + allows sending as a mask at all is to observe, 5. + +### Creating + +- **ME-12.** The server generates the address. The domain is `emailDomain` if + given, else the owning account's primary domain. `emailDomain` may be any + domain or alias domain the account is linked to. Anything else fails with + `invalidProperties` naming `emailDomain`. In a tenant, only the tenant's + domains qualify (multi-tenancy MT-3). +- **ME-13.** Address format, **Decision**: the local part is + `{emailPrefix}_{random}` when a prefix is given, else `{random}`, where + `{random}` is 12 characters from `a-z0-9`, about 62 bits. It's checked + against every address the server knows (accounts, aliases, lists, masks and + tombstones) and redrawn on collision. inbuxa-server doesn't copy upstream's + shape and doesn't need to. The expiry isn't encoded in the address, since + it's stored in `expiresAt`. +- **ME-14.** `maxMaskedAddresses` counts live masks: `pending`, `enabled`, + `disabled`. A create past it fails with `overQuota`. 0 turns creation off. +- **ME-15.** Create-rate limit per account, as Fastmail's API allows: past it, + create fails with `rateLimit`. **Decision**: 50 an hour per account, + configurable. +- **ME-16.** `createdBy` is set by the server from the authenticated client's + name (the OAuth client's name once SPEC.md §5.2 is in place). Fastmail's API + treats it as server-set. Upstream's API accepts a client-supplied value + (its docs show one in a create), so the `x:` API still accepts it when the + server has no client name of its own. +- **ME-17.** `forDomain` is stored as given. The Fastmail API asks integrators + for an origin only, but inbuxa-server doesn't reject paths, since existing + upstream records may hold them. + +### Who can do what + +- **ME-18.** A user manages its own masks: get, query, create, update, + destroy. Its role needs the `sysMaskedEmail*` permissions, which the default + user role carries. +- **ME-19.** An administrator with the same permissions can manage another + account's masks, for support. A tenant administrator can manage only masks + owned by accounts in its tenant (multi-tenancy MT-1). + +## The two APIs + +### Upstream's, unchanged + +`x:MaskedEmail/get`, `/query`, `/set` under `urn:stalwart:jmap`, standard RFC +8620 shapes, the record above, filtered by `accountId` in `/query`. Plus +`/changes`, if upstream offers it (to observe, 6). + +### Fastmail's + +Capability `https://www.fastmail.com/dev/maskedemail`, advertised in the +session and in the account capabilities of every account that may hold +masks. + +- `MaskedEmail/get` (with `ids: null` fetching all of an account's masks) and + `MaskedEmail/set`, standard RFC 8620 shapes, in the user's own JMAP account. +- Properties: `id`, `email`, `state`, `forDomain`, `description`, + `lastMessageAt`, `createdAt`, `createdBy`, `url`, and `emailPrefix` + (create-only). The same ids as the `x:` API, so an id is one mask whichever + API reads it. +- `description` defaults to the empty string. + +## ihasmail + +- A **Masked addresses** section in Settings: the list (address, description, + site, created, last mail, state), create with an optional description and + site, copy, switch between enabled, disabled (to Trash) and deleted, and + destroy with a warning that the address is gone for good. Pending masks are + hidden. +- **Compose:** a masked address can be chosen as the sender. Replying to mail + that came through a mask selects that mask by default (ME-11). +- **Reading:** a small marker on mail that arrived through a mask, naming it. +- **Administration:** an account's masks on its panel, for admins with the + permissions. +- Every string this adds is new translation work for ihasmail's nine + languages. + +## Acceptance tests + +1. Create with no arguments: gets a unique address on the account's primary + domain, state `pending` in the Fastmail API and `enabled: true` in `x:`. +2. Mail to a pending mask: delivered, state becomes `enabled`, + `lastMessageAt` set (ME-7). +3. Pending mask with no mail for 24 hours: removed and tombstoned (ME-8). +4. Fastmail `disabled`: mail is accepted and lands in Trash (ME-5). +5. Fastmail `deleted`, and upstream `enabled: false`: mail refused at + `RCPT TO` with the observed reply (ME-6). Both APIs read it back + consistently (ME-2). +6. Destroyed mask: refused as unknown, and its address is never issued again + (ME-3, ME-13). +7. `emailPrefix: "shop"` gives `shop_…`. `emailPrefix: "Shop!"` fails + `invalidProperties`. +8. `emailDomain` the account isn't linked to fails `invalidProperties` + (ME-12). +9. `maxMaskedAddresses` 2: the third fails `overQuota`. 0 blocks creation + (ME-14). +10. The 51st create in an hour fails `rateLimit` (ME-15). +11. A user can't read another user's masks. An admin can. A tenant admin can + only for its tenant (ME-18, ME-19). +12. **(compat)** INBUXA's existing masks all resolve by stored address and + deliver after cutover, and read back identically through `x:`. + +## To observe + +Settle against INBUXA before implementation, with a throwaway ordinary +account, never by reading upstream code: + +1. The SMTP reply for mail to a disabled mask, and to an expired one: code, + temporary or permanent, and text. +2. What an upstream address looks like: length, characters, where a prefix + goes, which domain is chosen by default. Needed only so ME-13 can't mint a + look-alike, and to confirm existing addresses fit in the tombstone and + lookup design. +3. What a delivered message shows: headers naming the mask, whether `To` is + rewritten. +4. Whether `mask+tag@domain` delivers. +5. Whether a user can send as a mask (as an identity, or through a + submission `MAIL FROM`), and what the recipient sees. +6. Whether `x:MaskedEmail/changes` exists, and whether masks can be queried + by fields other than `accountId`. +7. Whether an ordinary user holds the `sysMaskedEmail*` permissions by + default. +8. How many masks INBUXA already holds, over all accounts (count only), to + size the compatibility test. This needs an admin, or the operator can + report it.