Masked email feature spec
This commit is contained in:
@@ -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.
|
||||||
Reference in New Issue
Block a user