12 KiB
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
archiveDeletedItemsForset, 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/setdestroy, IMAPEXPUNGEandUID EXPUNGE, POP3DELEthenQUIT, the automatic emptying of Trash and Junk (expungeTrashAfter), and a Sieve action that removes a stored message. - Files:
FileNodedestroy, over JMAP or WebDAV. - Calendar events and contacts: destroy over JMAP, CalDAV or CardDAV.
- Sieve scripts: destroy over JMAP or ManageSieve.
- Email: JMAP
- 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
discardof 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.
archivedUntilisarchivedAtplus 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: requestRestoreon the item, overx:ArchivedItem/set. The server creates aRestoreArchivedItemtask 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,
$seenincluded. 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.
- 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,
- UD-9. Once restored, the archived record is removed. The restored item
is a new item with a new id, and JMAP
/changesreports 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/setdestroy). 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
archiveDeletedAccountsForset, 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 byaccountId),/set(updatestatus; destroy); the task and settings objects; the permission names. - New, Decision:
x:ArchivedItem/changes;/queryfilters on@typeandarchivedAtranges, and text over the summary fields, so ihasmail can offer "deleted in the last week" and search. Kept deleted accounts listed and restored throughx: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
- Archiving off: an emptied message is gone, and there's no archived record.
- Archiving 30 days:
Email/setdestroy, IMAP expunge, POP3 delete, and automatic Trash emptying each produce one archivedEmailrecord, witharchivedUntil30 days out (UD-1, UD-5). - Moving to Trash produces no record (UD-2).
- Restoring a message deleted from two labels puts it back in both, with its keywords and thread (UD-8).
- Restoring when its mailboxes are gone lands it in Inbox (UD-8).
- File, event, contact and script each restore to the right place, and a restored script is inactive (UD-8).
- The restored item has a new id, and the record is gone (UD-9).
- Restoring past the account's quota is refused, and the item stays archived (UD-10).
- Double restore restores once (UD-11).
- The user destroys an archived item: it's gone for good (UD-12).
- Retention lowered from 30 to 7 days: existing deadlines don't move (UD-5).
- Past
archivedUntil: not restorable, and gone after clean-up (UD-13). - Archive size doesn't count toward quota (UD-14).
- Deleted account: can't sign in, mail refused, name reserved, restorable by an admin with its data intact (UD-15 to UD-17).
- A user can't see another user's archive. A tenant admin sees only its tenant's (UD-7).
- (compat) Archived items already held at INBUXA read back unchanged
through
x:ArchivedItemafter 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.
- Whether INBUXA has archiving on now (
archiveDeletedItemsFor), and whether it holds any archived items. Sizes the compatibility test. - Which deletions produce a record: JMAP destroy, IMAP expunge, POP3,
automatic Trash and Junk emptying, a Sieve
discardat delivery. - What a record looks like for each kind, and what the email summary fields hold.
- Whether an ordinary user holds the
sysArchivedItem*permissions, and can restore and destroy its own items. - 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.
- 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. - Whether archived copies count toward the account's quota.
- Whether upstream's
x:ArchivedItem/querysupports any filter beyondaccountId, and whether/changesexists.