17 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 |
| 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
archiveDeletedItemsForset, 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/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.
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:ArchivedItemrecord, 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.
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. - 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: 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.
Decision, a deliberate improvement: upstream restores every message
to the Inbox, whatever mailboxes it was in (Trash included), with all
keywords dropped (
$seenand$flaggedgone), under a new id and a new thread. It keeps onlyreceivedAt(observed 5). That makes a restore look like new, unread mail, and loses the user's filing.
- 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, as upstream
(observed 5). 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), 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
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-15a. Decision (2026-09-18) on how it's kept: the
x:Accountrecord 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'sDestroyAccounttask, 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
DestroyAccounttask, due at the end of the period, shows it's still held (observed 6).
Interfaces
- Existing, unchanged:
x:ArchivedItem/get,/query,/set(updatestatus; destroy); the task and settings objects; the permission names. Upstream's/queryaccepts no filter at all, and its/getomitsstatusandaccountIdeven when asked (observed 8). inbuxa-server returns every property it lists. - 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). Decision (2026-09-18) on the shape: a method pair in the fork's namespace, underurn:inbuxa:jmap:inbuxa:DeletedAccount/getlists kept accounts (id,name,addresses,memberTenantId,deletedAt,keptUntil), andinbuxa:DeletedAccount/setrestores one (update{"restore": true, "password": "..."}, which writes the record back with the same id and cancels itsDestroyAccounttask) 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
- 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.
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.
- 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
ReloadSettingsaction. After that, archiving was immediate. - What produced a record. Email destroyed over JMAP, email destroyed from
Trash over JMAP, and email expunged over IMAP each produced one
Emailrecord within seconds. A contact card, calendar event, file and Sieve script destroyed over JMAP produced none. Automatic Trash emptying, POP3 and Sievediscardweren't tested. - The record.
@type,from,subject,receivedAt,size,archivedAt,archivedUntil(archivedAtplus the retention),blobId,id. - Permissions. The ordinary account holds all five
sysArchivedItem*permissions, but nottaskRestoreArchivedItem. It didn't need that one to restore. - Restore. Setting
status: requestRestorerestored 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.receivedAtwas kept. A message deleted from Trash also came back to Inbox. The archived record was removed on restore. - Deleted accounts. Destroying an account made sign-in fail (401) at
once. The account left the list and
get(notFound). Mail to it was refused550 5.1.2 Mailbox does not exist.ADestroyAccounttask 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. - Quota. The account's usage stayed at 185 bytes with three messages archived, and rose to 2,303 once two were restored.
- API.
x:ArchivedItem/changesis an unknown method./queryrejected every filter tried,accountIdincluded, asunsupportedFilter./getomittedstatusandaccountIdeven when named inproperties. 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.