Files
jcoffey-dev 4a9aa9c548 Masked email: rewrite to the owner at RCPT TO, create responses carry the address, admins query all masks (ME-4, ME-9, ME-13, ME-19)
Found by running system_tests, which masked email no longer stops:
- rcpt_resolve rewrites a live mask to its owner's address, so
  Delivered-To names the account; delivery recognizes the mask from the
  original recipient when it belongs to that account.
- x:MaskedEmail/set create responses carry the server-set email.
- x:MaskedEmail/query returns every mask to a server-level impersonate
  holder, and filters on accountId.
- The refusal for an unlinked emailDomain uses upstream's wording.
- The shared delivery test checks the fork's address format (ME-13).
- The masked email test's tenant domain uses manual DKIM, so its cleanup
  leaves nothing behind.
2026-09-18 18:29:19 -07:00

362 lines
19 KiB
Markdown

# 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 |
| Probes of INBUXA's live Enterprise server, 2026-09-18 (Stalwart 0.16.22), as an ordinary account | Observation | Everything under "Observed" |
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.
**Decision** (2026-09-18) on where: one new data-store subspace owned by the
fork (`_`), with its own SQL table, included in backup and restore. Not a
letter: SQL backends name each subspace's table after its byte, unquoted, so
Postgres and SQLite would fold `X` into upstream's `x`, and upstream already
uses every lowercase letter. It also
holds an index from address to mask, since upstream's record doesn't index
`email`, and a per-account change log for `/changes`. Masks written before
the fork are indexed on first start.
## 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`, see ME-6a |
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` with `550 5.1.2 Mailbox does not exist.`, upstream's own reply for
disabled and expired masks (observed 1). It's permanent, and identical to
the reply for an address that never existed, so a sender learns nothing
about the mask.
- **ME-6a.** **Decision**, a deliberate difference: an expired mask reads
`enabled: false` in the `x:` API. Upstream keeps reporting `enabled: true`
after expiry and keeps listing it (observed 1), which tells the user it
still works when it doesn't. Expired masks aren't removed automatically.
- **ME-7.** Arriving mail sets `lastMessageAt`, and moves a `pending` mask to
`enabled`.
- **ME-7a.** **Decision** (2026-09-18): a mask created through the Fastmail
API starts `pending` unless the create sets `state`. One created through
upstream's `x:` API, which ihasmail and existing clients use, starts
`enabled` and is never removed automatically.
- **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.
`To` and `Cc` are never rewritten (as upstream, observed 3). **Decision**,
an addition: an `X-Masked-Email` header names the mask it came through,
so filters and ihasmail can tell even when the mask was only BCC'd.
Upstream's `Delivered-To` names the account's real address, not the mask
(observed 3), and that stays as it is.
- **ME-10.** Sub-addressing (`mask+tag@domain`) on a mask works exactly as it
does on the account's own addresses, as it does upstream (observed 4).
### Sending
- **ME-11.** **Decision**, an addition: a user may send from any of their
masks in `pending`, `enabled` or `disabled` state, as they can from their
own aliases. A mask can be a JMAP `Identity`, and SMTP submission accepts it
as `MAIL FROM` and `From` for its owner. Upstream allows neither (observed
5), so a masked address today can receive but never reply. Replying to mail
that came through a mask defaults to sending from that mask: ihasmail's
job, made possible by ME-9.
### 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
`forbidden` naming `emailDomain`, as upstream (observed 2). A default create
lands on the account's own domain (observed 2). 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`. Upstream's addresses always contain a `.` (see
observed 2). This format never does, so a fork-issued address can't be
mistaken for an upstream one.
- **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. **Decision** (2026-09-18) on where: the environment variable
`INBUXA_MASKED_EMAIL_CREATE_RATE` (creates per hour, default 50, 0 for no
limit), read at startup, until the fork has a settings object of its own.
- **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 and stores a client-supplied
value (observed 2), 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: upstream stores
them as given (observed 2), so existing 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: an ordinary account holds all five (observed 7).
- **ME-19.** An administrator with the same permissions can manage another
account's masks, for support. **Decision** (2026-09-18) on who counts as
one: at server level, a principal holding `impersonate`; in a tenant, one
holding `sysAccountUpdate`, for accounts in its own tenant only. Either
also needs the matching `sysMaskedEmail*` permission. 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. Upstream's `/query` accepts only an
`accountId` filter, and it has no `/changes` (observed 6).
**Decision**, additions: `/changes`, so ihasmail can keep its list current
without refetching; `/query` filters on `enabled`, `forDomain` and text
(address and description); and a `state` in every `/get` response, as RFC 8620
expects and upstream omits.
### 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
These go in the INBUXA fork of ihasmail, not public ihasmail, which stays
Stalwart-facing (SPEC.md §5).
- 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. Through the Fastmail API it's `pending`, and reads `enabled: true`
in `x:`. Through `x:` it's `enabled` in both (ME-7a).
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 `forbidden`
(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:`.
## Implementation status
Built 2026-09-18 from this spec, clean-room, under the multi-tenancy hand-off
brief's rules. The rules live in `crates/features` (`inbuxa-features`, module
`masked_email`); the JMAP glue in `crates/jmap/src/inbuxa/`, with Fastmail's
object type in `crates/jmap-proto/src/object/fastmail_masked_email.rs`;
upstream files carry hooks marked `inbuxa:`. Acceptance tests 1 to 11 pass as
`tests/src/system/masked_email.rs`, with ME-9, ME-10, ME-11 and `/changes`.
- **ME-1 to ME-15, ME-17 to ME-19:** built.
- **ME-16:** built for the `x:` API, which stores a client-supplied
`createdBy`. The server-set name is **deferred** until sign-in goes through
OAuth (contract C-8): with Basic auth there's no client name, so a mask
created through the Fastmail API has none.
- **ihasmail changes** belong to ihasmail-inbuxa and aren't part of this
repository.
- **Test 12 (compat)** is written as `masked_email_compat`, ignored, and unrun
until a copy of INBUXA's data with masks made on it is provided. Its doc
comment says how to run it.
- **Known limits, not requirements of this spec:**
- A pending mask past its 24 hours is removed the next time anything reads
it or mail arrives for it, not by a timer. Nothing can see it in between,
so the effect is the same (ME-8).
- Creating an account or alias doesn't consult the mask index, so an
account could be given an address a mask holds; the account then wins
delivery. A random 12-character mask address makes this unlikely.
- At `RCPT TO` a live mask is rewritten to its owner's address, as
upstream's shared tests expect, so `Delivered-To` names the account
(ME-9). Delivery recognizes the mask from the original recipient
(`ORCPT`) when that mask belongs to the recipient. A sender who knows a
mask can set that `ORCPT` on mail to the owner's own address, which at
most files its own mail to Trash (through a disabled mask) or adds a header
naming the mask.
- Upstream's shared delivery test expected a `.` in a generated address.
ME-13 deliberately differs, so that one assertion checks the fork's
format instead, marked `inbuxa: ME-13`.
## Observed
Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart
0.16.22), as the ordinary throwaway account on `ttlhost.com`, over JMAP and
SMTP submission. The probes created four masks and six small messages, all to
the account itself: nothing was sent off the server. Two recipients were
refused, which triggered no IP ban. Afterwards the masks and messages were
deleted, and nothing was left. No upstream code was read.
1. **Refused delivery.** A disabled mask and an expired mask were each refused
at `RCPT TO` on the submission connection with `550 5.1.2 Mailbox does not
exist.`: permanent, and worded as for a nonexistent address. The expired
mask kept `enabled: true` in its record and stayed in the list.
2. **Upstream's addresses and creation.** A default mask is
`{16 chars}.{24 chars}@{account's domain}`. With `emailPrefix` the prefix
replaces the first part (`probe_shop.{24 chars}@…`). Every character is
`a-z0-9`. The 24-character part began with the same 9 characters for every
mask created in the same second, so it carries data. It wasn't decoded, and
doesn't need to be (ME-13). A prefix with a capital and `!` was refused
`invalidProperties`. A domain the account isn't linked to was refused
`forbidden` with `properties: ["emailDomain"]`. A client-supplied
`createdBy`, and a `forDomain` with a path, were both stored as given.
3. **What arrives.** `To` shows the mask, unchanged. `Delivered-To` shows the
account's real address.
4. **Sub-addressing.** `mask+news@domain` was delivered to the account.
5. **Sending as a mask.** Refused both ways. JMAP `Identity/set` gave
`invalidProperties` ("E-mail address not configured for this account"), and
SMTP `MAIL FROM` the mask gave `501 5.5.4 You are not allowed to send from
this address.`
6. **API.** `x:MaskedEmail/changes` is an unknown method. `/query` accepts
only `accountId` as a filter: `enabled`, `text`, `email` and `forDomain`
are all `unsupportedFilter`. `/get` returns no `state`.
7. **Permissions.** The ordinary account holds all five `sysMaskedEmail*`
permissions.
8. **How many masks INBUXA holds.** Operator's estimate, 2026-09-18: fewer
than 20 special addresses in all, 7 on the operator's own account and the
rest postmaster addresses. The operator confirmed none of the 7 has the
masked format, so they're ordinary aliases, and INBUXA most likely holds no
masks at all. Aliases carry over unchanged and aren't part of this feature.
With no masks, the compatibility test (acceptance 12) has no existing
masks to carry over, and instead checks that upstream-shaped addresses
created on a copy before cutover still deliver after it. Count masks
exactly (`x:MaskedEmail/query` as an admin) during the cutover dry run
(SPEC.md §7).