Masked email: record what INBUXA's Enterprise server actually does
This commit is contained in:
+77
-45
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user