Undelete feature spec

This commit is contained in:
2026-09-18 09:42:38 -07:00
parent f9c19a153b
commit e73744e30b
+243
View File
@@ -0,0 +1,243 @@
# 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 |
| Observation of INBUXA's live server | Observation | Everything under "To observe" once settled |
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:
- **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`.
### 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.
Upstream's placement for each kind is to observe (5). Where it's sensible
it's matched, since users of both will expect the same result.
- **UD-9.** Once restored, the archived record is removed. 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). **Decision**: "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.** **Decision**: archived copies don't count toward the account's or
tenant's quota while archived. 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.
- **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. How upstream exposes
kept accounts, and whether it can restore them, is to observe (6). If
upstream offers no restore, this is an addition.
## Interfaces
- **Existing, unchanged:** `x:ArchivedItem/get`, `/query` (filter by
`accountId`), `/set` (update `status`; destroy); the task and settings
objects; the permission names.
- **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.
## To observe
Settle against INBUXA before implementation, never by reading upstream code.
Most of these need archiving on at INBUXA, which is a server-wide setting
change.
1. Whether INBUXA has archiving on now (`archiveDeletedItemsFor`), and
whether it holds any archived items. Sizes the compatibility test.
2. Which deletions produce a record: JMAP destroy, IMAP expunge, POP3,
automatic Trash and Junk emptying, a Sieve `discard` at delivery.
3. What a record looks like for each kind, and what the email summary fields
hold.
4. Whether an ordinary user holds the `sysArchivedItem*` permissions, and
can restore and destroy its own items.
5. Where a restored item lands: mailboxes, keywords and thread for email; the
original folder, calendar, address book and script state for the others.
Whether the record disappears afterwards.
6. What happens to a deleted account under `archiveDeletedAccountsFor`: can it
sign in, is mail refused and how, is its name reserved, and how does an
admin see and restore it.
7. Whether archived copies count toward the account's quota.
8. Whether upstream's `x:ArchivedItem/query` supports any filter beyond
`accountId`, and whether `/changes` exists.