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.
362 lines
19 KiB
Markdown
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).
|