Move the specs under docs/spec, ahead of the source import
This commit is contained in:
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user