Move the specs under docs/spec, ahead of the source import

This commit is contained in:
2026-09-18 10:21:43 -07:00
parent 826e8bc97f
commit dedcc0fe94
6 changed files with 3 additions and 3 deletions
+302
View File
@@ -0,0 +1,302 @@
# 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.
## 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-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.
- **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. 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
- 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 `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:`.
## 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).
+336
View File
@@ -0,0 +1,336 @@
# Feature spec: multi-tenancy
Status: draft, 2026-09-18. Feature 1 in SPEC.md §4.
## Provenance
Written for the clean room (SPEC.md §3). Everything here comes from these
sources, and nothing else:
| Source | License | Used for |
|---|---|---|
| Stalwart's registry schema: `crates/registry/src/schema/*.rs` and `resources/schema/schema.json.gz`, upstream `d9dee0a` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Object shapes, field meanings, enum values, permission names, what upstream flags as Enterprise |
| Stalwart documentation, "Tenants" (`website` repo, `docs/auth/authorization/tenants.md`) | Unlicensed public documentation: facts used, prose not copied | Isolation, the permission ceiling, quota semantics, branding |
| ihasmail FEATURES.md, "Tenants", and its admin code | AGPL-3.0-or-later, ours | Behavior observed against a live Enterprise server |
| RFC 8620 | IETF | JMAP semantics for errors and `/set` |
| Probes of INBUXA's live Enterprise server, 2026-09-18 (Stalwart 0.16.22) | Observation | Everything under "Observed" |
No Enterprise-only file or snippet was used. The session that drafted this
had seen short Enterprise snippets while surveying the license split, so it
writes specs only. Where this spec needs a behavior no public source settles,
it settles it by observation (see "Observed") or makes a decision of its own, marked
**Decision**. It never fills a gap from memory of upstream code.
## What it is
A tenant is a separate organization on one server: its own accounts, groups,
mailing lists, domains and settings, and an administrator who manages only
what is in it. One tenant can't see or reach another's resources. The server
operator decides how much of the server each tenant may use: which
permissions its people can hold, how many of each thing it can create, and
how much storage it can consume.
Upstream ships this only in its Enterprise Edition. inbuxa-server ships it to
everybody, always on. There is no edition check.
## Data model
Unchanged from upstream, so existing data opens as it is (SPEC.md §7).
### The tenant object, `x:Tenant`
| Field | Type | Set by | Meaning |
|---|---|---|---|
| `name` | string | admin | The tenant's name. There is no separate description |
| `createdAt` | UTC date-time | server | When it was created |
| `logo` | string, nullable | admin | An image URL or a data URL, shown to the tenant's people |
| `roles` | `Roles`: `Default`, or `Custom` with a list of roles | admin | The roles the tenant holds, which are the most its people can be given |
| `permissions` | `Permissions`: `Inherit`, `Merge` or `Replace`, with enabled and disabled lists | admin | The permissions the tenant may grant |
| `quotas` | map from `TenantStorageQuota` to an unsigned number | admin | Limits; a missing key is no limit |
| `usedDiskQuota` | size in bytes | server | Storage used by all members together |
Permission prefix: `sysTenant`. Permissions: `sysTenantGet`,
`sysTenantCreate`, `sysTenantUpdate`, `sysTenantDestroy`, `sysTenantQuery`,
and `taskTenantMaintenance`.
### Quota keys, `TenantStorageQuota`
`maxAccounts`, `maxGroups`, `maxDomains`, `maxMailingLists`, `maxRoles`,
`maxOauthClients`, `maxDkimKeys`, `maxDnsServers`, `maxDirectories`,
`maxAcmeProviders`, and `maxDiskQuota` (bytes).
### Membership, `memberTenantId`
A nullable reference to an `x:Tenant`. Null means the object belongs to no
tenant and is managed at server level. It appears on:
- principals: `UserAccount`, `GroupAccount`, `MailingList`, `Role`;
- `Domain`;
- signing and DNS: `Dkim1Signature`, `Dkim2Signature`, every `DnsServer*`
provider, `AcmeProvider`;
- directories: `LdapDirectory`, `SqlDirectory`, `OidcDirectory`;
- `OAuthClient`;
- incoming reports: `ArfExternalReport`, `DmarcExternalReport`,
`TlsExternalReport`.
### Elsewhere
- `x:Authentication.defaultTenantRoleIds`: the roles a tenant gets when its
`roles` is `Default`.
- Task `TenantMaintenance`, with `tenantId` and `maintenanceType`. The only
type is `recalculateQuota`.
- Store maintenance type `resetTenantQuotas`: reset every tenant's usage
figure.
- Event and metric `limit.tenant-quota`: a tenant limit was reached and the
operation was refused.
- `x:Cache.tenants`: cache size for tenants.
Upstream flags these fields as Enterprise, and in inbuxa-server they're
ordinary: every `memberTenantId` listed above,
`Authentication.defaultTenantRoleIds` and `Domain.logo`. The other flagged
fields belong to other features' specs.
## Required behavior
Each requirement has an ID, and tests name the IDs they check.
### Isolation
- **MT-1.** A principal in a tenant can reach only objects with the same
`memberTenantId`. Reach covers JMAP `/get`, `/query`, `/changes` and `/set`
on every object type that carries the field, directory lookups, sharing and
access rights, and any list an admin screen shows.
- **MT-2.** Objects in no tenant (`memberTenantId` null) are server-level. A
principal in a tenant can't read or change them, except what every account
already reads about itself: its own account, its own settings, and the
server's public capabilities.
- **MT-3.** Any write that would link an object to one in a different
tenant is refused with `invalidForeignKey`, naming the object it can't
link to: a group member, a list member or owner, a role, a sharing grant, a
domain's ACME or DNS provider. "Different" includes no tenant. A tenant's
domain can't use a server-level provider, even when a server-level admin
makes the change (observed 1). Shared infrastructure is therefore
per-tenant: each tenant brings its own providers.
- **MT-4.** Mail flow isn't isolation. Mail between addresses in different
tenants is delivered like any other mail, through the normal delivery path.
Tenants separate administration and resources, not the network.
- **MT-5.** Queued and outgoing mail are resources. A tenant administrator sees
and acts on queued messages whose recipients are on the tenant's own
domains, whoever sent them, and never sees anything else in the queue
(observed 4). **Decision** for the sender side, not yet observed: it also
sees messages its own principals sent, until they leave the queue.
### Membership
- **MT-6.** Only an administrator in no tenant, with the matching `sys*`
permission, can create a tenant, delete one, or change any object's
`memberTenantId`. A tenant administrator can't move anything into or out of
a tenant, their own included.
- **MT-7.** A principal's tenant always matches its domain's tenant. A
principal on a domain in tenant T is in T. A principal on a domain in no
tenant is in no tenant. On create, `memberTenantId` defaults to the domain's
tenant. A write that sets any other value is refused with
`invalidForeignKey` naming the domain, which is upstream's own refusal
(observed 7).
**Decision**, a deliberate difference: upstream doesn't default the tenant.
An account created on a tenant's domain without one lands in no tenant, a
server-level account on a tenant's domain. inbuxa-server never creates that
state. Existing accounts found in it (none at INBUXA, possible elsewhere)
are left working and listed in the admin dashboard for an operator to
resolve.
- **MT-8.** A domain can move into a tenant only from no tenant, and out of one
only back to no tenant. It moves only when no principal on it belongs to
anything other than the destination. **Decision**, a deliberate improvement
on upstream (observed 8): upstream lets a domain leave a tenant while the
tenant's accounts are still on it. They stay in the tenant, on a domain
that isn't, and can still sign in.
- **MT-9.** Creating a domain inside a tenant also puts its DKIM keys in that
tenant. Upstream generates two per domain (observed 9).
- **MT-10.** A tenant can be deleted only when nothing references it. Otherwise
deletion is refused with `objectIsLinked`, listing what still refers to it.
**Decision**: ihasmail already enforces this in its interface, and the server
should too.
### Administering a tenant
- **MT-11.** A tenant's administrator is an account inside the tenant that
holds administrative permissions (upstream: the `Admin` role, which inside a
tenant carries 290 permissions against 641 at server level; observed 3). Within the tenant it can do what those
permissions allow, on every object type that carries `memberTenantId`.
Everything it creates lands in its own tenant (observed 1). It never needs
to, and may not, set `memberTenantId` itself. `memberTenantId` isn't
returned to it at all (observed 3).
- **MT-12.** A tenant administrator can read its own tenant's `x:Tenant` object
(name, logo, quotas, usage) but can't change it. Changing the tenant object
itself is server-level. **Decision**, a deliberate difference: upstream
refuses the read with `forbidden` (observed 3), so a tenant admin can't see
its own limits or usage. ihasmail needs them to warn before a limit is hit.
### The permission ceiling
- **MT-13.** A principal's effective permissions are its own, cut down to
what its tenant allows. A permission the tenant doesn't hold has no effect
on anyone in the tenant, whoever granted it. Granting it isn't an error. It
just does nothing.
- **MT-14.** What a tenant allows is computed from its `roles` and its
`permissions`:
1. **Roles.** `Default` means the roles in
`Authentication.defaultTenantRoleIds`; `Custom` means the listed roles.
The permissions of all those roles, taken together, are the base.
2. **Permissions.** `Inherit` uses the base unchanged. `Merge` adds the
enabled list to the base. `Replace` uses only the enabled list and
ignores the base.
3. **Disabled wins.** In `Merge` and `Replace`, the disabled list is removed
last. A disabled permission is never allowed, however it was reached.
- **MT-15.** The ceiling can never exceed what the server grants. No setting
on a tenant can give its people a permission the server has disabled for
them.
- **MT-16.** Changing a tenant's roles, permissions or quotas takes effect for
its people without a restart and without them signing in again. The next
request is judged by the new rules. **Decision**, a deliberate improvement:
upstream caches each principal's permissions, and a change to the tenant
hadn't reached an already-authenticated admin after seven minutes
(observed 6). A change must invalidate the cached permissions of everyone it
affects.
### Quotas
- **MT-17.** Count quotas. Creating an object that would take a tenant past
its count limit for that kind is refused with `overQuota`, and the server
emits `limit.tenant-quota`. Moving a domain into a tenant counts its
principals and keys against the tenant's limits first, and is refused the
same way if any limit would be crossed.
- **MT-18.** Existing objects over a lowered limit stay. Only new ones are
refused.
- **MT-19.** Disk quota. `maxDiskQuota` bounds the total storage of every
member together. A message that would take the total past it isn't
delivered, even if usage is still under the limit (observed 2). Per-account
quotas still apply as well, whichever is reached first.
- **MT-19a.** The refusal matches upstream: local delivery answers
`451 4.3.0 Organization over quota.`, a temporary failure, so the message
waits in the queue and retries until the queue gives up. Submission itself
still succeeds, so the sender learns nothing until the delay notice.
**Decision** on top: when a sender inside the server submits to a
recipient whose tenant is over quota, say so at submission time too (a
JMAP `EmailSubmission` warning, not a refusal).
- **MT-20.** `usedDiskQuota` is kept current as members' usage changes: it
moved on the delivery itself (observed 2). It is never written by a client.
- **MT-21.** The `recalculateQuota` task recomputes one tenant's
`usedDiskQuota` from its members' actual usage. `resetTenantQuotas`
recomputes every tenant. Both can run while the server is live.
### Branding
- **MT-22.** A signed-in principal can read the logo that applies to it: its
domain's `logo` if set, else its tenant's `logo` if set, else none.
**Decision** on the order: a domain is more specific than a tenant. Exposed
over JMAP so ihasmail can draw it (see "Interfaces").
- **MT-23.** The server never fetches a logo URL itself. ihasmail draws URL
logos through its image proxy, as it does today.
## Interfaces
- **Existing, unchanged:** `x:Tenant/get`, `/set`, `/query`, `/changes`; the
`memberTenantId` field on the types listed above; the permission names; the
task and maintenance types; the event.
- **New:** the signed-in principal's applicable logo (MT-22). Proposed as a
read-only property on the principal's own account object in the fork's
namespace, with the exact shape settled in the contract spec (SPEC.md §5.2).
- **Errors:** RFC 8620 `SetError` types, as named above. Each refusal names
the property or limit involved, so ihasmail can say which one.
## ihasmail changes
- Drop the "Tenants are a Stalwart Enterprise feature" notice and the edition
check in front of the Tenants page. Keep `SHOW_ENTERPRISE_NOTICES` for
talking to upstream Stalwart.
- Show the applicable logo (MT-22) in the app for the signed-in user.
- Show `overQuota` and `limit.tenant-quota` refusals in plain words, naming the
limit.
- The domain-leaves-tenant guard it enforces today (MT-8) stays, and becomes a
second line of defense rather than the only one.
## Acceptance tests
Every test runs against inbuxa-server built with no Enterprise code. The ones
marked **(compat)** also run against a copy of INBUXA's data.
1. Tenant admin in T lists accounts: sees only T's (MT-1).
2. Tenant admin in T gets a U account by id: `notFound`, not `forbidden`.
**Decision**: don't confirm that another tenant's object exists.
3. Tenant admin adds a U account to a T group: `forbidden` (MT-3).
4. Mail from a T address to a U address is delivered (MT-4).
5. Tenant admin sets `memberTenantId` on anything: refused (MT-6, MT-11).
6. Account created on a T domain is in T with no `memberTenantId` sent (MT-7).
7. Domain with T accounts moved out of T: refused (MT-8).
8. Tenant with one account deleted: `objectIsLinked` (MT-10).
9. User in T granted a permission T lacks: the permission has no effect
(MT-13).
10. `Replace` with an enabled permission that's also disabled: not allowed
(MT-14).
11. Lowering T's roles takes effect on the user's next request with no new
sign-in (MT-16).
12. `maxAccounts` 2: the third account is refused `overQuota` and the event is
emitted (MT-17). Lowering it to 1 keeps both existing accounts (MT-18).
13. `maxDiskQuota` reached across two accounts: delivery to a third member is
refused (MT-19).
14. `recalculateQuota` after hand-corrupting `usedDiskQuota`: restored
(MT-21).
15. **(compat)** INBUXA's existing tenants, their members and quotas read back
unchanged, and each tenant admin sees exactly what it saw before.
## Observed
Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart
0.16.22), over JMAP and SMTP submission. The probes ran as a temporary
server-level admin, and created two tenants, two domains under `ttlhost.com`
with manual certificates, four accounts, three ACME providers pointed at
Let's Encrypt staging, and two probe messages. Everything was deleted
afterwards, and a sweep of every affected object type found nothing left.
No upstream code was read.
1. **References across tenants.** A tenant's domain can't use an ACME
provider in no tenant, or one in another tenant. Both are refused with
`invalidForeignKey`, even when a server-level admin makes the change. A
provider the tenant admin creates lands in its tenant and works. The tenant
admin can't see server-level providers at all.
2. **Full tenant disk quota.** With usage at 1,191 bytes and a 1,200-byte
limit, the next message wasn't delivered: the check is on what delivery
would bring the total to. Local delivery answered `451 4.3.0 Organization
over quota.` at `RCPT TO` (temporary). Submission was accepted, and the
message stayed queued with a retry due in 2 minutes, a delay notice due
after 1 day, and expiry after 3 days. Usage rose on the delivery itself.
3. **What a tenant admin can read.** Its tenant's accounts, domain, DKIM keys
and its own ACME providers, roles, DNS providers and queue. It gets
`forbidden` for listeners, certificates, system settings and every tenant
object, its own included. Objects in no tenant or another tenant, fetched by
id, come back in `notFound`. `memberTenantId` isn't returned to it. Its
`Admin` role carried 290 permissions (server-level admin: 641).
4. **The queue.** Tenant 1's admin saw exactly the two queued messages
addressed to its domain, both sent from tenant 2. It saw none of the other
23 entries in the server's queue. The sender side (a tenant admin seeing its
own people's outgoing mail) wasn't tested: there was no admin in tenant 2.
5. **Directory search.** A tenant user's JMAP `Principal/query`, with and
without a text filter, returned only its own tenant's two principals: not
the other tenant's, and not the server-level accounts.
6. **Changes to a tenant's roles and permissions.** Setting tenant 1's
`permissions` to `Replace` with nothing enabled, then its `roles` to an
empty custom set, left its already-signed-in admin with all 290
permissions for 2.5 minutes. A new admin created during the change was
refused everything, including its JMAP session (HTTP 403): the ceiling
works, but permissions are cached per principal. After the roles were
restored, the new admin was still refused 6.7 minutes later, when polling
stopped. The cache lifetime wasn't measured. The `permissions` field on its
own wasn't isolated from the `roles` field, because only cached principals
saw that change.
7. **An account's tenant.** Creating accounts on a tenant's domain without
`memberTenantId` put them in no tenant. Giving one a tenant other than its
domain's was refused with `invalidForeignKey` naming the domain. The same
happened for a no-tenant domain with a tenant set.
8. **A domain leaving its tenant.** Allowed while one of the tenant's accounts
was on it. The account kept its tenant and could still sign in.
9. **DKIM.** Each new domain got two DKIM keys, each in the domain's tenant.
Not yet settled: whether deleting a tenant that still holds objects is
refused (the probe accounts were deleted first), and the sender side of 4.
Both are low-risk to leave for implementation-time tests.
+289
View File
@@ -0,0 +1,289 @@
# Feature spec: undelete
Status: draft, 2026-09-18. Feature 3 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:ArchivedItem` and its five variants, `x:TaskRestoreArchivedItem`, `x:DataRetention`, the related enums and permissions, upstream `d9dee0a` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | The stored records, field meanings, statuses, the restore task, the settings |
| Stalwart documentation: "Storage overview" (Un-deleting emails), the ArchivedItem and DataRetention object references, "Tasks" (Restore archived item) | Unlicensed public documentation: facts used, prose not copied | What counts as deleted, who can recover, how restore is asked for, the retention switch |
| RFC 8620, RFC 8621 | IETF | JMAP semantics; `Email` and mailbox behavior on restore |
| Probes of INBUXA's live Enterprise server, 2026-09-18 (Stalwart 0.16.22) | Observation | Everything under "Observed" |
No Enterprise-only file or snippet was used. The drafting session writes specs
only. Gaps are settled by observation or marked **Decision**, never filled
from memory of upstream code.
## What it is
When something is deleted for good (an email emptied from Trash, a file, a
calendar event, a contact, a Sieve script), the server keeps a copy for a set
period instead of destroying it at once. Within that period the user, or an
administrator, can bring it back. After it, the copy is destroyed.
This covers the mistakes that hurt most: emptying Trash, a filter that
deleted the wrong thing, a client that expunged a folder, a deleted script.
It isn't a backup (it doesn't protect against losing the server) and it
isn't a legal hold (a user can still destroy their own copies, see
UD-12).
Upstream ships this only in its Enterprise Edition. inbuxa-server ships it to
everybody. Off by default, as upstream, and switched on by a single duration.
## Data model
Unchanged from upstream, so existing archives open as they are (SPEC.md §7).
### Settings, on `x:DataRetention`
| Field | Meaning |
|---|---|
| `archiveDeletedItemsFor` | Duration. How long a deleted item is kept. Unset: nothing is kept, deletion is immediate. This is the only switch |
| `archiveDeletedAccountsFor` | Duration. How long a deleted account is kept. Unset: accounts are destroyed at once |
The clean-up that finally destroys expired copies runs on the existing
`dataCleanupSchedule` and `blobCleanupSchedule` of the same object.
### The record, `x:ArchivedItem`
One object with five variants, chosen by `@type`:
| Variant | Its own fields |
|---|---|
| `Email` | `from`, `subject`, `receivedAt`, `size` (read-only) |
| `FileNode` | `name`, `createdAt` |
| `CalendarEvent` | `title`, `startTime`, `createdAt` |
| `ContactCard` | `name`, `createdAt` |
| `SieveScript` | `name`, `content`, `createdAt` |
Every variant also has `accountId` (the owner), `archivedAt`,
`archivedUntil` (when it's destroyed if not restored), `blobId` (the kept
copy) and `status`, which is `archived` (kept, restorable) or
`requestRestore` (restore asked for).
Permissions: `sysArchivedItemGet`, `sysArchivedItemQuery`,
`sysArchivedItemCreate`, `sysArchivedItemUpdate`, `sysArchivedItemDestroy`,
and `taskRestoreArchivedItem`.
### The restore task, `x:TaskRestoreArchivedItem`
Task type `RestoreArchivedItem`, carrying the `accountId`, the
`archivedItemType`, the `blobId`, the original `createdAt`, the
`archivedUntil` deadline, and the task's own `status` and `due`.
## Required behavior
### What gets kept
- **UD-1.** With `archiveDeletedItemsFor` set, a copy is kept whenever an item
of one of the five kinds is permanently removed from an account, whatever
removed it. **Decision**, a deliberate extension: upstream defines all five
kinds but archives only email. A contact, calendar event, file and Sieve
script destroyed over JMAP produced no record (observed 2). inbuxa-server
archives all five:
- **Email:** JMAP `Email/set` destroy, IMAP `EXPUNGE` and
`UID EXPUNGE`, POP3 `DELE` then `QUIT`, the automatic emptying of Trash
and Junk (`expungeTrashAfter`), and a Sieve action that removes a stored
message.
- **Files:** `FileNode` destroy, over JMAP or WebDAV.
- **Calendar events and contacts:** destroy over JMAP, CalDAV or CardDAV.
- **Sieve scripts:** destroy over JMAP or ManageSieve.
- **UD-2.** Moving to Trash isn't deletion, and keeps nothing: the item still
exists. Only the permanent removal is archived.
- **UD-3.** Mail rejected or discarded before delivery (spam refused at SMTP,
a Sieve `discard` of incoming mail) was never stored, and isn't kept.
**Decision**: an item that never reached the account can't be undeleted.
Confirm against upstream (to observe, 2).
- **UD-4.** The copy is whole: for email, the full message as stored, with its
mailboxes and keywords recorded so restore can put them back (UD-8). The
archived record shows only the summary fields above.
- **UD-5.** `archivedUntil` is `archivedAt` plus the retention in force when
the item was archived. Changing the setting later doesn't move existing
deadlines. **Decision**: a shorter setting must never silently destroy what
a user was told they could still recover.
- **UD-6.** Switching archiving off (unsetting the duration) stops new copies.
Copies already kept stay until their own `archivedUntil`.
- **UD-6a.** A change to either retention setting takes effect at once.
**Decision**, a fix: upstream applies it only after a settings reload
(observed 1). Until then, deletions are destroyed as if archiving were off,
while the setting reads as on.
### Recovering
- **UD-7.** A user sees and restores its own archived items. An administrator
with the permissions sees and restores any account's it manages. In a
tenant, a tenant administrator only its tenant's (multi-tenancy MT-1).
Whether ordinary users hold these permissions by default is to observe, 4.
- **UD-8.** Restore is asked for by setting `status: requestRestore` on the
item, over `x:ArchivedItem/set`. The server creates a `RestoreArchivedItem`
task for it, and the task:
- **Email:** puts the message back in the mailboxes it was in when deleted,
if they still exist. If none do, it goes to the Inbox. **Decision** on
Trash: a message deleted *from* Trash is restored to Trash only if Trash is
all it was in. Otherwise it goes to the other mailboxes it was in. Keywords
come back as they were, `$seen` included. It rejoins its thread.
- **Files:** back to their original folder if it exists, else the root, with
a `(restored)` suffix if the name is taken.
- **Calendar events and contacts:** back to their original calendar or
address book if it exists, else the default.
- **Sieve scripts:** back as an inactive script, suffixed if the name is
taken. A restored script never activates itself.
**Decision**, a deliberate improvement: upstream restores every message
to the Inbox, whatever mailboxes it was in (Trash included), with all
keywords dropped (`$seen` and `$flagged` gone), under a new id and a new
thread. It keeps only `receivedAt` (observed 5). That makes a restore look
like new, unread mail, and loses the user's filing.
- **UD-9.** Once restored, the archived record is removed, as upstream
(observed 5). The restored item is a new item with a new id, and JMAP
`/changes` reports it as created.
- **UD-10.** A restore still counts against quota. It's refused if it would
take the account, or its tenant, past a limit. The item stays archived, and
the task records why.
- **UD-11.** Restoring is idempotent: asking twice, or asking while a restore
task is already running, restores once.
### Destroying
- **UD-12.** A user may permanently destroy its own archived items early
(`x:ArchivedItem/set` destroy), as upstream allows (observed 8). "Delete
means delete" must stay possible for the person whose data it is. Undelete protects against
accidents, not against the user. An operator who needs retention against
users' wishes needs a legal-hold feature, which this isn't.
- **UD-13.** Past `archivedUntil`, the scheduled clean-up destroys the record
and its copy. Nothing expired is restorable, even if clean-up hasn't run yet.
### Storage
- **UD-14.** Archived copies don't count toward the account's or tenant's
quota while archived, as upstream (observed 7). Counting them would make deleting mail to
free space fail. They do count in server storage reporting, and ihasmail's
admin dashboard shows the archive's total size per account.
### Deleted accounts
- **UD-15.** With `archiveDeletedAccountsFor` set, destroying an account keeps
the whole account (mail, files, calendars, contacts, scripts, settings) for
that period. The account can't sign in or receive mail while deleted.
Mail to it is refused as for an unknown address.
- **UD-16.** Its name and addresses stay reserved while it's kept, so nobody
else can take them and receive its mail. **Decision**, a fix: upstream
doesn't reserve them. A new account with the deleted one's name was created
at once, while the old one's data was still waiting to be destroyed
(observed 6).
- **UD-17.** A server-level administrator (or a tenant administrator, for its
tenant) can restore a kept account within the period. It comes back as it
was, with the same id and a new password to be set. **Decision**, an
addition: upstream offers no restore. A deleted account disappears from the
account list at once, and only a pending `DestroyAccount` task, due at the
end of the period, shows it's still held (observed 6).
## Interfaces
- **Existing, unchanged:** `x:ArchivedItem/get`, `/query`, `/set` (update
`status`; destroy); the task and settings objects; the permission names.
Upstream's `/query` accepts no filter at all, and its `/get` omits `status`
and `accountId` even when asked (observed 8). inbuxa-server returns every
property it lists.
- **New, Decision:** `x:ArchivedItem/changes`; `/query` filters on `@type`
and `archivedAt` ranges, and text over the summary fields, so ihasmail can
offer "deleted in the last week" and search. Kept deleted accounts listed
and restored through `x:Account` (shape settled with UD-17 once observed).
## ihasmail
- **Recently deleted**, a view in Mail (and in Files, Calendar and Contacts
for their kinds). It lists what's restorable, newest first, with how long is
left, searchable, with **Restore** and **Delete forever**. Visible only when
the server has archiving on.
- **After emptying Trash:** the confirmation says how long the mail stays
recoverable, when archiving is on.
- **Administration:** an account's archive on its panel (count, size, restore
on the user's behalf). Deleted accounts in the accounts list, marked, with
**Restore** until their deadline. The retention settings in the storage
settings.
- Every string this adds is new translation work for ihasmail's nine
languages.
## Acceptance tests
1. Archiving off: an emptied message is gone, and there's no archived record.
2. Archiving 30 days: `Email/set` destroy, IMAP expunge, POP3 delete, and
automatic Trash emptying each produce one archived `Email` record, with
`archivedUntil` 30 days out (UD-1, UD-5).
3. Moving to Trash produces no record (UD-2).
4. Restoring a message deleted from two labels puts it back in both, with its
keywords and thread (UD-8).
5. Restoring when its mailboxes are gone lands it in Inbox (UD-8).
6. File, event, contact and script each restore to the right place, and a
restored script is inactive (UD-8).
7. The restored item has a new id, and the record is gone (UD-9).
8. Restoring past the account's quota is refused, and the item stays archived
(UD-10).
9. Double restore restores once (UD-11).
10. The user destroys an archived item: it's gone for good (UD-12).
11. Retention lowered from 30 to 7 days: existing deadlines don't move
(UD-5).
12. Past `archivedUntil`: not restorable, and gone after clean-up (UD-13).
13. Archive size doesn't count toward quota (UD-14).
14. Deleted account: can't sign in, mail refused, name reserved, restorable
by an admin with its data intact (UD-15 to UD-17).
15. A user can't see another user's archive. A tenant admin sees only its
tenant's (UD-7).
16. **(compat)** Archived items already held at INBUXA read back unchanged
through `x:ArchivedItem` after cutover, and restore.
## Observed
Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart
0.16.22). With the operator's approval, both retention settings were set to 1
day, made effective with a settings reload at 16:57:09 UTC, and restored to
their recorded values (unset) with a second reload at 17:01:50 UTC. The probes
ran as the throwaway ordinary account and a temporary admin. Everything the
probes created was deleted afterwards. The one exception is a deleted probe
account whose scheduled destroy task, due 2026-09-19 17:01 UTC, removes its one
message. Deletions by other users during those 4 minutes 41 seconds are kept
until their own 1-day deadline, then destroyed. No upstream code was read.
1. **Before, and switching on.** Archiving was off (both settings unset) and
INBUXA held no archived items, so nothing needs carrying over at cutover.
Setting the duration over JMAP read back as set, but deletions weren't
archived, not even after an account purge task, until a `ReloadSettings`
action. After that, archiving was immediate.
2. **What produced a record.** Email destroyed over JMAP, email destroyed from
Trash over JMAP, and email expunged over IMAP each produced one `Email`
record within seconds. A contact card, calendar event, file and Sieve script
destroyed over JMAP produced none. Automatic Trash emptying, POP3 and Sieve
`discard` weren't tested.
3. **The record.** `@type`, `from`, `subject`, `receivedAt`, `size`,
`archivedAt`, `archivedUntil` (`archivedAt` plus the retention), `blobId`,
`id`.
4. **Permissions.** The ordinary account holds all five `sysArchivedItem*`
permissions, but not `taskRestoreArchivedItem`. It didn't need that one to
restore.
5. **Restore.** Setting `status: requestRestore` restored within 10 seconds.
A message that had been in Inbox and a second mailbox, flagged and read, came
back in Inbox only, with no keywords, a new id and a new thread.
`receivedAt` was kept. A message deleted from Trash also came back to Inbox.
The archived record was removed on restore.
6. **Deleted accounts.** Destroying an account made sign-in fail (401) at
once. The account left the list and `get` (`notFound`). Mail to it was
refused `550 5.1.2 Mailbox does not exist.` A `DestroyAccount` task was
scheduled for the end of the period, carrying the id, name and domain. No
archived item appeared, and no restore path was visible. A new account with
the same name was created successfully straight away. With retention off, a
destroyed account left no task: it was deleted immediately.
7. **Quota.** The account's usage stayed at 185 bytes with three messages
archived, and rose to 2,303 once two were restored.
8. **API.** `x:ArchivedItem/changes` is an unknown method. `/query` rejected
every filter tried, `accountId` included, as `unsupportedFilter`. `/get`
omitted `status` and `accountId` even when named in `properties`. The user
could destroy its own archived item.
Not settled: automatic Trash and Junk emptying, POP3 deletion, Sieve
`discard`; whether a pending `DestroyAccount` task can be cancelled to keep an
account; and why the test account's usage read 486 bytes after cleanup
against 185 before (probably deletions not yet subtracted, not archiving).
None blocks the spec.