Files
jcoffey-dev ecbdfd533b Undelete: deleted accounts are kept for their period, hold their addresses, and are restored or destroyed through inbuxa:DeletedAccount (UD-15 to UD-17a)
With archiveDeletedAccountsFor set, a destroyed account's record is kept in
the fork subspace with its id, its DestroyAccount task is due at the end of
the period, and its shares are suspended both ways. Its addresses can't be
taken by new accounts, aliases, lists or masks. inbuxa:DeletedAccount/get
lists kept accounts to server and tenant administrators; /set restores one
with a new password (same id, task cancelled, shares reinstated) or destroys
it now. The destroy task also clears undelete's own records.
Acceptance test 14; test 16 written as the ignored undelete_compat.
2026-09-18 21:35:48 -07:00

348 lines
19 KiB
Markdown

# 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.
**Decision** (2026-09-18) on where: what restore needs beyond the kept
copy (an email's mailboxes and keywords, a file's folder and name, an
event's calendar, a contact's address book) is kept beside the unchanged
`x:ArchivedItem` record, in the fork's own subspace (`_`, as masked email),
keyed by archived item id. Items archived before the fork have none, and
restore as upstream did: email to the Inbox.
- **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-15a.** **Decision** (2026-09-18) on how it's kept: the `x:Account`
record is removed as upstream removes it, so sign-in and delivery fail as
they already do, and a copy of it is kept in the fork's own subspace until
the deadline, with its id. The data waits for upstream's `DestroyAccount`
task, as it does today.
- **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).
- **UD-17a.** **Decision** (2026-09-18) on sharing: "as it was" includes
shares, in both directions. At deletion, upstream revokes what the account
shared with others, and leaves what others shared with it in place (inert,
since it can't sign in). inbuxa-server records both directions in the
fork's own subspace and revokes both, so nobody reaches a deleted account's
data and it reaches nobody's. Restoring reinstates each share whose other
account still exists, with its permissions. Destroying it for good drops
the record.
## 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).
**Decision** (2026-09-18) on the shape: a method pair in the fork's
namespace, under `urn:inbuxa:jmap`: `inbuxa:DeletedAccount/get` lists kept
accounts (`id`, `name`, `addresses`, `memberTenantId`, `deletedAt`,
`keptUntil`), and `inbuxa:DeletedAccount/set` restores one (update
`{"restore": true, "password": "..."}`, which writes the record back with
the same id and cancels its `DestroyAccount` task) or destroys it for good
(destroy, which runs the task now). Server administrators see every kept
account; tenant administrators their own tenant's (MT-1).
## ihasmail
These go in the INBUXA fork of ihasmail, not public ihasmail, which stays
Stalwart-facing (SPEC.md §5).
- **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.
## Implementation status
Built 2026-09-18 from this spec, clean-room, under the multi-tenancy hand-off
brief's rules. The rules live in `crates/features` (`inbuxa-features`, module
`undelete`); the JMAP glue in `crates/jmap/src/inbuxa/`, with the deleted
account object type in `crates/jmap-proto/src/object/inbuxa_deleted_account.rs`;
restoring in `crates/services/src/task_manager/`; upstream files carry hooks
marked `inbuxa:`. Acceptance tests 1 to 15 pass as
`tests/src/system/undelete.rs`, with `/changes` and the `/query` filters.
- **UD-1 to UD-17a:** built.
- **ihasmail changes** belong to ihasmail-inbuxa and aren't part of this
repository.
- **Test 16 (compat)** is written as `undelete_compat`, ignored, and unrun
until a copy of INBUXA's data with archived items made on it is provided.
Its doc comment says how to run it.
- **Known limits, not requirements of this spec:**
- A kept account holds the addresses it had under its domain's names at
deletion. A domain renamed while it's kept doesn't move the hold.
- The hold is checked when accounts, aliases, mailing lists and masks are
created or changed through `x:`. A mask made through Fastmail's API
isn't checked; its random address makes a clash unlikely.
- A restored user keeps its other credentials (app passwords, API keys,
its one-time-password setup) as they were; only the password is new.
- Restoring needs `sysAccountCreate` and the right to grant everything the
account holds, so a tenant administrator can't bring back an account
with more than it could create.
## 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.