Masked email: record what INBUXA's Enterprise server actually does

This commit is contained in:
2026-09-18 09:31:56 -07:00
parent 0fbf6b7d47
commit dc1ddb2893
+77 -45
View File
@@ -12,7 +12,7 @@ Written for the clean room (SPEC.md §3). Sources, and nothing else:
| 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 | | 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 | | 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 | | RFC 8620 | IETF | `/get` and `/set` semantics, `SetError` types |
| Observation of INBUXA's live server | Observation | Everything under "To observe" once settled | | 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 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 drafting session writes specs only, and gaps are settled by observation or
@@ -88,7 +88,7 @@ keeps one state per mask, and each API shows it in its own terms.
| `disabled` | Accepted, filed straight to Trash | `disabled` | `true` | | `disabled` | Accepted, filed straight to Trash | `disabled` | `true` |
| `deleted` | Refused | `deleted` | `false` | | `deleted` | Refused | `deleted` | `false` |
| (destroyed) | Refused, as an unknown address | not returned | not returned | | (destroyed) | Refused, as an unknown address | not returned | not returned |
| (expired) | Refused | `deleted` | `false` | | (expired) | Refused | `deleted` | `false`, see ME-6a |
Writes map back: Writes map back:
@@ -116,34 +116,45 @@ accepted, which is the fact `enabled` reports.
- **ME-5.** `disabled` delivers into the account's Trash mailbox, skipping - **ME-5.** `disabled` delivers into the account's Trash mailbox, skipping
the user's filing rules but not spam checks. the user's filing rules but not spam checks.
- **ME-6.** `deleted`, expired and destroyed masks refuse the message at - **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 `RCPT TO` with `550 5.1.2 Mailbox does not exist.`, upstream's own reply for
observe, 1), so senders see the same result whichever server they meet. 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 - **ME-7.** Arriving mail sets `lastMessageAt`, and moves a `pending` mask to
`enabled`. `enabled`.
- **ME-8.** A `pending` mask with no mail within 24 hours of creation is - **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 removed, per Fastmail's rule, and tombstoned. It's hidden from ihasmail's
list while pending, as Fastmail's own interface hides it. list while pending, as Fastmail's own interface hides it.
- **ME-9.** Delivered mail keeps the masked address visible to the user: in - **ME-9.** Delivered mail keeps the masked address visible to the user.
the `To` or `Cc` header as sent, and in a `Delivered-To` header naming the `To` and `Cc` are never rewritten (as upstream, observed 3). **Decision**,
mask, so filters and the reader can tell which mask it came through (to an addition: an `X-Masked-Email` header names the mask it came through,
observe, 3: match upstream if it already does this). 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 - **ME-10.** Sub-addressing (`mask+tag@domain`) on a mask works exactly as it
does on the account's own addresses (to observe, 4). does on the account's own addresses, as it does upstream (observed 4).
### Sending ### Sending
- **ME-11.** A user may send from any of their masks in `pending`, `enabled` - **ME-11.** **Decision**, an addition: a user may send from any of their
or `disabled` state, as they can from their own aliases. Replying to mail masks in `pending`, `enabled` or `disabled` state, as they can from their
that came through a mask should default to sending from that mask: own aliases. A mask can be a JMAP `Identity`, and SMTP submission accepts it
ihasmail's job (see "ihasmail"), made possible by ME-9. Whether upstream as `MAIL FROM` and `From` for its owner. Upstream allows neither (observed
allows sending as a mask at all is to observe, 5. 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 ### Creating
- **ME-12.** The server generates the address. The domain is `emailDomain` if - **ME-12.** The server generates the address. The domain is `emailDomain` if
given, else the owning account's primary domain. `emailDomain` may be any 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 domain or alias domain the account is linked to. Anything else fails with
`invalidProperties` naming `emailDomain`. In a tenant, only the tenant's `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). domains qualify (multi-tenancy MT-3).
- **ME-13.** Address format, **Decision**: the local part is - **ME-13.** Address format, **Decision**: the local part is
`{emailPrefix}_{random}` when a prefix is given, else `{random}`, where `{emailPrefix}_{random}` when a prefix is given, else `{random}`, where
@@ -151,7 +162,9 @@ accepted, which is the fact `enabled` reports.
against every address the server knows (accounts, aliases, lists, masks and against every address the server knows (accounts, aliases, lists, masks and
tombstones) and redrawn on collision. inbuxa-server doesn't copy upstream's 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 shape and doesn't need to. The expiry isn't encoded in the address, since
it's stored in `expiresAt`. 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`, - **ME-14.** `maxMaskedAddresses` counts live masks: `pending`, `enabled`,
`disabled`. A create past it fails with `overQuota`. 0 turns creation off. `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, - **ME-15.** Create-rate limit per account, as Fastmail's API allows: past it,
@@ -159,18 +172,18 @@ accepted, which is the fact `enabled` reports.
configurable. configurable.
- **ME-16.** `createdBy` is set by the server from the authenticated client's - **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 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 treats it as server-set. Upstream's API accepts and stores a client-supplied
(its docs show one in a create), so the `x:` API still accepts it when the value (observed 2), so the `x:` API still accepts it when the server has no
server has no client name of its own. client name of its own.
- **ME-17.** `forDomain` is stored as given. The Fastmail API asks integrators - **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 for an origin only, but inbuxa-server doesn't reject paths: upstream stores
upstream records may hold them. them as given (observed 2), so existing records may hold them.
### Who can do what ### Who can do what
- **ME-18.** A user manages its own masks: get, query, create, update, - **ME-18.** A user manages its own masks: get, query, create, update,
destroy. Its role needs the `sysMaskedEmail*` permissions, which the default destroy. Its role needs the `sysMaskedEmail*` permissions, which the default
user role carries. user role carries: an ordinary account holds all five (observed 7).
- **ME-19.** An administrator with the same permissions can manage another - **ME-19.** An administrator with the same permissions can manage another
account's masks, for support. A tenant administrator can manage only masks account's masks, for support. A tenant administrator can manage only masks
owned by accounts in its tenant (multi-tenancy MT-1). owned by accounts in its tenant (multi-tenancy MT-1).
@@ -180,8 +193,13 @@ accepted, which is the fact `enabled` reports.
### Upstream's, unchanged ### Upstream's, unchanged
`x:MaskedEmail/get`, `/query`, `/set` under `urn:stalwart:jmap`, standard RFC `x:MaskedEmail/get`, `/query`, `/set` under `urn:stalwart:jmap`, standard RFC
8620 shapes, the record above, filtered by `accountId` in `/query`. Plus 8620 shapes, the record above. Upstream's `/query` accepts only an
`/changes`, if upstream offers it (to observe, 6). `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 ### Fastmail's
@@ -237,26 +255,40 @@ masks.
12. **(compat)** INBUXA's existing masks all resolve by stored address and 12. **(compat)** INBUXA's existing masks all resolve by stored address and
deliver after cutover, and read back identically through `x:`. deliver after cutover, and read back identically through `x:`.
## To observe ## Observed
Settle against INBUXA before implementation, with a throwaway ordinary Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart
account, never by reading upstream code: 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. The SMTP reply for mail to a disabled mask, and to an expired one: code, 1. **Refused delivery.** A disabled mask and an expired mask were each refused
temporary or permanent, and text. at `RCPT TO` on the submission connection with `550 5.1.2 Mailbox does not
2. What an upstream address looks like: length, characters, where a prefix exist.`: permanent, and worded as for a nonexistent address. The expired
goes, which domain is chosen by default. Needed only so ME-13 can't mint a mask kept `enabled: true` in its record and stayed in the list.
look-alike, and to confirm existing addresses fit in the tombstone and 2. **Upstream's addresses and creation.** A default mask is
lookup design. `{16 chars}.{24 chars}@{account's domain}`. With `emailPrefix` the prefix
3. What a delivered message shows: headers naming the mask, whether `To` is replaces the first part (`probe_shop.{24 chars}@…`). Every character is
rewritten. `a-z0-9`. The 24-character part began with the same 9 characters for every
4. Whether `mask+tag@domain` delivers. mask created in the same second, so it carries data. It wasn't decoded, and
5. Whether a user can send as a mask (as an identity, or through a doesn't need to be (ME-13). A prefix with a capital and `!` was refused
submission `MAIL FROM`), and what the recipient sees. `invalidProperties`. A domain the account isn't linked to was refused
6. Whether `x:MaskedEmail/changes` exists, and whether masks can be queried `forbidden` with `properties: ["emailDomain"]`. A client-supplied
by fields other than `accountId`. `createdBy`, and a `forDomain` with a path, were both stored as given.
7. Whether an ordinary user holds the `sysMaskedEmail*` permissions by 3. **What arrives.** `To` shows the mask, unchanged. `Delivered-To` shows the
default. account's real address.
8. How many masks INBUXA already holds, over all accounts (count only), to 4. **Sub-addressing.** `mask+news@domain` was delivered to the account.
size the compatibility test. This needs an admin, or the operator can 5. **Sending as a mask.** Refused both ways. JMAP `Identity/set` gave
report it. `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.
Not settled: 8, how many masks INBUXA holds across all accounts. It needs
admin rights or the operator's count, and only sizes the compatibility test.