jmap: Done exits the shared Inbox, Trash confirms first
Done on a writable account now queues the mail out of the shared Inbox
into Archive, keeping labels and folders — the push self-limits against
fresh memberships, so mail another client already filed out is left
untouched. Trash stays the rare destructive verb, behind a dialog.

Assisted-by: GLM-5.3 via pi
change wxuwwzuvulzoyoxmnmllsrotsrkswynx
commit d81546564469cad052f84db7518fd0ae512afa05
author Alpha Chen <alpha@kejadlen.dev>
date
parent qmtpxynz
diff --git a/assets/docket.css b/assets/docket.css
index 88a25a6..03cf243 100644
--- a/assets/docket.css
+++ b/assets/docket.css
@@ -118,6 +118,19 @@ button { font: inherit; color: inherit; }
 .opt:hover, .opt.cur { background: var(--gl-color-surface-fill); }
 .check { font: 500 var(--gl-step--2)/1 var(--gl-font-mono); }
 
+/* Deleting is the one destructive value: quiet until hovered. */
+.del-btn { all: unset; box-sizing: border-box; cursor: pointer; padding: 4px 6px; border-radius: var(--gl-radius); color: var(--gl-color-text-tertiary); font: var(--gl-step--2) var(--gl-font-mono); transition: background var(--gl-dur-fast) var(--gl-ease); }
+.del-btn:hover { background: var(--gl-color-surface-fill); color: var(--gl-color-text-primary); }
+.del-btn:focus-visible { outline: 2px solid var(--gl-color-accent); outline-offset: 1px; }
+
+/* The trash confirm: a small centered modal over a dimmed page. */
+dialog { margin: auto; border: none; border-radius: var(--gl-radius); padding: var(--gl-space-m); background: var(--gl-color-surface-card); color: var(--gl-color-text-primary); box-shadow: var(--gl-shadow-float); max-width: 380px; }
+dialog::backdrop { background: rgb(0 0 0 / .4); }
+dialog h3 { font: 500 var(--gl-step-0)/1.3 var(--gl-font-text); margin: 0 0 var(--gl-space-2xs) 0; }
+dialog p { margin: 0 0 var(--gl-space-s) 0; color: var(--gl-color-text-secondary); }
+dialog form { display: flex; justify-content: flex-end; gap: var(--gl-space-xs); }
+.btn.danger { background: var(--gl-color-danger); color: #fff; }
+
 .comment-box { display: flex; align-items: flex-end; gap: var(--gl-space-xs); }
 
 /* Gloss toast: an ink slab, mono, no border. */
diff --git a/migrations/0004_pending_deletes.sql b/migrations/0004_pending_deletes.sql
new file mode 100644
index 0000000..6803f75
--- /dev/null
+++ b/migrations/0004_pending_deletes.sql
@@ -0,0 +1,6 @@
+-- Deleting is intent, like filing: the route marks the thread Done
+-- here, and the poll loop turns rows into Email/destroy (task sm),
+-- which Fastmail answers by moving the mail to Trash.
+CREATE TABLE pending_deletes (
+    message INTEGER PRIMARY KEY REFERENCES messages (id) ON DELETE CASCADE
+) STRICT;
diff --git a/migrations/0005_pending_archives.sql b/migrations/0005_pending_archives.sql
new file mode 100644
index 0000000..2d0871c
--- /dev/null
+++ b/migrations/0005_pending_archives.sql
@@ -0,0 +1,8 @@
+-- Finishing is intent too (the Done-archives design): marking a
+-- message Done on a writable account queues the poll loop to move it
+-- out of the shared Inbox and into Archive, keeping Docket/ labels and
+-- folder memberships. The push is self-limiting — mail another client
+-- already filed out of the Inbox is left untouched, its row cleared.
+CREATE TABLE pending_archives (
+    message INTEGER PRIMARY KEY REFERENCES messages (id) ON DELETE CASCADE
+) STRICT;
diff --git a/src/jmap.rs b/src/jmap.rs
index 4070677..a7b18b8 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -6,8 +6,9 @@
 //!
 //! Keeping up with the server is polling for now (task lylzwoyo):
 //! `poll_once` applies `Email/changes` and `Mailbox/changes` since the
-//! states the last sync left, and drains queued filings (task rn) with
-//! `Email/set`. Push replaces the timer later (task nwylszul); the
+//! states the last sync left, and drains the queued filings, archives,
+//! and deletions (tasks rn and sm) with `Email/set` and
+//! `Email/destroy`. Push replaces the timer later (task nwylszul); the
 //! changes application stays.
 
 use std::collections::{BTreeMap, BTreeSet};
@@ -433,12 +434,17 @@ struct Layout {
     pub inbox: Option<String>,
     /// The Sent mailbox's id.
     pub sent: Option<String>,
+    /// The Archive mailbox's id: where Done's exit from the Inbox
+    /// lands.
+    pub archive: Option<String>,
     /// Label mailbox id → the state it carries.
     labels: BTreeMap<String, State>,
     /// Mailbox ids in the Docket namespace: the labels and the parent.
     docket: BTreeSet<String>,
     /// Folder mailbox id → its name and ACLs.
     folders: BTreeMap<String, Folder>,
+    /// Every mailbox's ACLs, for the moves that cross system boxes.
+    rights: BTreeMap<String, MailboxRights>,
 }
 
 impl Layout {
@@ -452,10 +458,12 @@ impl Layout {
             .map(|m| m.id.as_str())
             .collect();
         for mailbox in mailboxes {
+            layout.rights.insert(mailbox.id.clone(), mailbox.my_rights);
             match mailbox.role.as_deref() {
                 Some("inbox") => layout.inbox = Some(mailbox.id.clone()),
                 Some("sent") => layout.sent = Some(mailbox.id.clone()),
-                // The other system boxes (Archive, Drafts, Junk, Trash)
+                Some("archive") => layout.archive = Some(mailbox.id.clone()),
+                // The other system boxes (Drafts, Junk, Trash)
                 // are neither folders nor labels.
                 Some(_) => {}
                 None => {
@@ -582,6 +590,44 @@ fn filing_allowed(current: &BTreeMap<String, bool>, layout: &Layout, target: Opt
     leaving && entering
 }
 
+/// The memberships an archive leaves the message in: out of the Inbox,
+/// into Archive, with labels, folders, and other system boxes kept.
+/// None when there is no exit to make — the mail already left the
+/// Inbox, or the server has no Archive mailbox — so the caller clears
+/// the intent without touching anything.
+fn archived_mailboxes(
+    current: &BTreeMap<String, bool>,
+    layout: &Layout,
+) -> Option<BTreeMap<String, bool>> {
+    let archive = layout.archive.as_deref()?;
+    if !current.contains_key(layout.inbox.as_deref()?) {
+        return None;
+    }
+    let mut next: BTreeMap<String, bool> = current
+        .iter()
+        .filter(|(id, member)| **member && Some(id.as_str()) != layout.inbox.as_deref())
+        .map(|(id, _)| (id.clone(), true))
+        .collect();
+    next.insert(archive.to_owned(), true);
+    Some(next)
+}
+
+/// Whether the ACLs permit the archive: the Inbox gives the message
+/// up, and Archive takes it.
+fn archive_allowed(layout: &Layout) -> bool {
+    let leaving = layout
+        .inbox
+        .as_deref()
+        .and_then(|id| layout.rights.get(id))
+        .is_none_or(|rights| rights.may_remove_items);
+    let entering = layout
+        .archive
+        .as_deref()
+        .and_then(|id| layout.rights.get(id))
+        .is_some_and(|rights| rights.may_add_items);
+    leaving && entering
+}
+
 /// Parses an `Email/set` reply's verdict on each id.
 fn parse_set_emails(args: Value) -> Result<SetEmailsReply, JmapError> {
     serde_json::from_value(args).map_err(|source| JmapError::Malformed {
@@ -590,6 +636,14 @@ fn parse_set_emails(args: Value) -> Result<SetEmailsReply, JmapError> {
     })
 }
 
+/// Parses an `Email/destroy` reply's verdict on each id.
+fn parse_destroy_emails(args: Value) -> Result<DestroyEmailsReply, JmapError> {
+    serde_json::from_value(args).map_err(|source| JmapError::Malformed {
+        what: "Email/destroy",
+        source,
+    })
+}
+
 /// How much mail one sync brought in.
 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
 struct Imported {
@@ -619,6 +673,10 @@ pub struct PollCounts {
     pub mailboxes: bool,
     /// Filings pushed server-side.
     pub filed: usize,
+    /// Archives pushed server-side — Done's exit from the Inbox.
+    pub archived: usize,
+    /// Deletions pushed server-side.
+    pub deleted: usize,
     /// Whether the server disowned the stored states and a full
     /// re-import ran instead.
     pub resynced: bool,
@@ -819,6 +877,25 @@ struct SetEmailsReply {
     not_updated: BTreeMap<String, Value>,
 }
 
+/// `Email/destroy` arguments (RFC 8621 §5.5).
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct DestroyEmails<'a> {
+    account_id: &'a str,
+    destroy: &'a [String],
+}
+
+/// The outcome half of an `Email/destroy` reply: which ids went, and
+/// which the server refused.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct DestroyEmailsReply {
+    #[serde(default)]
+    destroyed: Vec<String>,
+    #[serde(default)]
+    not_destroyed: BTreeMap<String, Value>,
+}
+
 /// Opens sessions: one per credential, against Fastmail unless a test
 /// points the session URL at a stub.
 #[derive(Clone)]
@@ -985,6 +1062,29 @@ impl Client {
         parse_set_emails(args)
     }
 
+    /// Destroys emails with one `Email/destroy`, returning the
+    /// server's verdict on each id.
+    async fn destroy_emails(
+        &self,
+        api_url: &str,
+        token: &str,
+        account_id: &str,
+        destroy: &[String],
+    ) -> Result<DestroyEmailsReply, JmapError> {
+        let args = self
+            .call(
+                api_url,
+                token,
+                "Email/destroy",
+                &DestroyEmails {
+                    account_id,
+                    destroy,
+                },
+            )
+            .await?;
+        parse_destroy_emails(args)
+    }
+
     /// Opens the credential's session, records its account, and imports
     /// its mail. What the server permits is the policy (ADR 1), and
     /// [`Import::account`] is the seam; the upserts underneath make this
@@ -1173,10 +1273,10 @@ impl Client {
 
     /// One poll cycle: `/changes` for mailboxes and email since the
     /// states the account was left in, applying what moved, then the
-    /// queued filings go out. A `cannotCalculateChanges` reply means
-    /// the server disowns those states (RFC 8620 §5.2), so the cycle
-    /// re-imports from scratch and the caller carries the fresh states
-    /// forward.
+    /// queued filings, archives, and deletions go out. A
+    /// `cannotCalculateChanges` reply means the server disowns those
+    /// states (RFC 8620 §5.2), so the cycle re-imports from scratch
+    /// and the caller carries the fresh states forward.
     pub async fn poll_once(
         &self,
         credential: &Credential,
@@ -1235,6 +1335,12 @@ impl Client {
         counts.filed = self
             .push_files(&session, &token, id, &credential.name, &sync.layout, store)
             .await?;
+        counts.archived = self
+            .push_archives(&session, &token, id, &credential.name, &sync.layout, store)
+            .await?;
+        counts.deleted = self
+            .push_deletes(&session, &token, id, &credential.name, store)
+            .await?;
 
         loop {
             let page = match self
@@ -1382,6 +1488,125 @@ impl Client {
         store.clear_pending_files(&clear)?;
         Ok(filed)
     }
+
+    /// Drains the queued archives — what Done means on a writable
+    /// account: the mail leaves the shared Inbox for Archive, labels
+    /// and folders kept. Self-limiting against fresh memberships: mail
+    /// another client already filed out of the Inbox is left alone,
+    /// its intent cleared silently. ACL-refused entries stay queued and
+    /// nag the log until an admin grants them; server refusals and ids
+    /// the server no longer carries are dropped with a warning instead
+    /// of retried forever.
+    #[allow(clippy::too_many_arguments)]
+    async fn push_archives(
+        &self,
+        session: &Session,
+        token: &str,
+        account_id: &str,
+        slug: &str,
+        layout: &Layout,
+        store: &Store,
+    ) -> Result<usize, Error> {
+        let pending = store.pending_archives(slug)?;
+        if pending.is_empty() {
+            return Ok(0);
+        }
+        // Memberships come fresh from the server: the store keeps only
+        // the folder they derive from.
+        let mut current: BTreeMap<String, BTreeMap<String, bool>> = BTreeMap::new();
+        for chunk in pending.chunks(PAGE) {
+            let ids: Vec<String> = chunk.iter().map(|p| p.jmap_id.clone()).collect();
+            let (emails, _) = self
+                .emails(&session.api_url, token, account_id, &ids)
+                .await?;
+            for email in emails {
+                current.insert(email.id.clone(), email.mailbox_ids);
+            }
+        }
+        let mut updates: BTreeMap<String, EmailPatch> = BTreeMap::new();
+        let mut clear: Vec<MessageId> = Vec::new();
+        for p in &pending {
+            let Some(memberships) = current.get(&p.jmap_id) else {
+                tracing::warn!(jmap_id = %p.jmap_id, "archive dropped: the mail is gone server-side");
+                clear.push(p.message);
+                continue;
+            };
+            let Some(next) = archived_mailboxes(memberships, layout) else {
+                // Already out of the Inbox, or no Archive to land in:
+                // nothing to do either way. A vanished Archive mailbox
+                // deserves a trace; an already-exited message doesn't.
+                if layout.archive.is_none() {
+                    tracing::warn!(jmap_id = %p.jmap_id, "archive dropped: the server has no Archive mailbox");
+                }
+                clear.push(p.message);
+                continue;
+            };
+            if !archive_allowed(layout) {
+                tracing::warn!("archive held: mailbox ACLs refuse it");
+                continue;
+            }
+            updates.insert(p.jmap_id.clone(), EmailPatch { mailbox_ids: next });
+        }
+        let mut archived = 0usize;
+        let entries: Vec<(String, EmailPatch)> = updates.into_iter().collect();
+        for chunk in entries.chunks(PAGE) {
+            let update: BTreeMap<String, EmailPatch> = chunk.iter().cloned().collect();
+            let reply = self
+                .set_emails(&session.api_url, token, account_id, &update)
+                .await?;
+            for (id, error) in &reply.not_updated {
+                tracing::warn!(jmap_id = %id, %error, "the server refused an archive");
+            }
+            archived = archived.saturating_add(reply.updated.len());
+            // Refused ids clear too: the server's verdict stands, and
+            // retrying would only requeue the refusal.
+            clear.extend(chunk.iter().filter_map(|(id, _)| {
+                pending.iter().find(|p| &p.jmap_id == id).map(|p| p.message)
+            }));
+        }
+        store.clear_pending_archives(&clear)?;
+        Ok(archived)
+    }
+
+    /// Drains the account's queued deletions (task sm) with
+    /// `Email/destroy` — Fastmail's answer to a destroy is moving the
+    /// mail to Trash. Ids the server refused or no longer carries are
+    /// dropped with a warning rather than retried; the thread already
+    /// went Done when the deletion was queued.
+    async fn push_deletes(
+        &self,
+        session: &Session,
+        token: &str,
+        account_id: &str,
+        slug: &str,
+        store: &Store,
+    ) -> Result<usize, Error> {
+        let pending = store.pending_deletes(slug)?;
+        if pending.is_empty() {
+            return Ok(0);
+        }
+        let mut deleted = 0usize;
+        let mut clear = Vec::new();
+        let ids: Vec<String> = pending.iter().map(|p| p.jmap_id.clone()).collect();
+        for chunk in ids.chunks(PAGE) {
+            let reply = self
+                .destroy_emails(&session.api_url, token, account_id, chunk)
+                .await?;
+            for (id, error) in &reply.not_destroyed {
+                tracing::warn!(jmap_id = %id, %error, "the server refused a deletion");
+            }
+            deleted = deleted.saturating_add(reply.destroyed.len());
+            // Refused ids clear too: the server's verdict stands, and
+            // retrying would only requeue the refusal.
+            clear.extend(
+                chunk
+                    .iter()
+                    .filter_map(|id| pending.iter().find(|p| &p.jmap_id == id).map(|p| p.message)),
+            );
+        }
+        store.clear_pending_deletes(&clear)?;
+        Ok(deleted)
+    }
 }
 
 /// The token stays in its file (DESIGN.md, Storage), read fresh each
@@ -1884,6 +2109,65 @@ mod tests {
         assert!(!filing_allowed(&locked, &hoarder, Some("Receipts")));
     }
 
+    #[test]
+    fn archives_exit_the_inbox_and_keep_everything_else() {
+        let layout = Layout::of(&mailboxes());
+        assert_eq!(layout.archive.as_deref(), Some("M-archive"));
+
+        let in_inbox = BTreeMap::from([
+            ("M-in".to_string(), true),
+            ("M-watch".to_string(), true),
+            ("M-school".to_string(), true),
+        ]);
+        assert_eq!(
+            archived_mailboxes(&in_inbox, &layout),
+            Some(BTreeMap::from([
+                ("M-watch".to_string(), true),
+                ("M-school".to_string(), true),
+                ("M-archive".to_string(), true),
+            ]))
+        );
+
+        // Already out of the Inbox: no exit to make, nothing touched.
+        let filed_out = BTreeMap::from([("M-school".to_string(), true)]);
+        assert_eq!(archived_mailboxes(&filed_out, &layout), None);
+
+        // No Archive mailbox to land in: no exit either.
+        let mut bare = Layout::of(&mailboxes());
+        bare.archive = None;
+        assert_eq!(archived_mailboxes(&in_inbox, &bare), None);
+
+        // Full rights allow it.
+        assert!(archive_allowed(&layout));
+
+        let full = rights(true, true, true, true, true, true, true, true);
+
+        // The Inbox hoarding or Archive shut stops it.
+        let shut = |inbox: Value, archive: Value| {
+            Layout::of(
+                &[
+                    mailbox("M-in", "Inbox", Some("inbox"), inbox),
+                    mailbox("M-archive", "Archive", Some("archive"), archive),
+                ]
+                .into_iter()
+                .map(|m| serde_json::from_value::<Mailbox>(m).unwrap())
+                .collect::<Vec<_>>(),
+            )
+        };
+        let mut hoard = full.clone();
+        hoard["mayRemoveItems"] = json!(false);
+        assert!(!archive_allowed(&shut(hoard, full.clone())));
+        let mut closed = full.clone();
+        closed["mayAddItems"] = json!(false);
+        assert!(!archive_allowed(&shut(full.clone(), closed)));
+    }
+
+    #[test]
+    fn a_destroy_reply_that_wont_parse_fails() {
+        let err = parse_destroy_emails(json!({"destroyed": 3})).unwrap_err();
+        assert!(err.to_string().contains("malformed Email/destroy"), "{err}");
+    }
+
     #[test]
     fn set_emails_serializes_and_replies_parse() {
         let update = BTreeMap::from([(
diff --git a/src/routes.rs b/src/routes.rs
index c879bce..b63fa09 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -43,6 +43,7 @@ pub fn router(state: AppState) -> Router {
         .route("/search", get(search))
         .route("/messages/{id}/state", post(set_state))
         .route("/messages/{id}/folder", post(set_folder))
+        .route("/messages/{id}/delete", post(delete_message))
         .route("/messages/{id}/assignees", post(toggle_assignee))
         .route("/threads/{id}/comments", post(add_comment))
         .route("/undo", post(undo))
@@ -252,6 +253,16 @@ async fn edit(
     Ok(Redirect::to(safe_back(back)))
 }
 
+async fn delete_message(
+    Extract(state): Extract<AppState>,
+    Me(me): Me,
+    Path(id): Path<MessageId>,
+    Form(form): Form<BackForm>,
+) -> Result<Redirect, Error> {
+    state.store.delete(&me.login, id)?;
+    Ok(Redirect::to(safe_back(&form.back)))
+}
+
 #[derive(Deserialize)]
 struct CommentForm {
     text: String,
diff --git a/src/store.rs b/src/store.rs
index 28ccec0..5e1433f 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -23,6 +23,8 @@ const MIGRATIONS: &[&str] = &[
     include_str!("../migrations/0001_init.sql"),
     include_str!("../migrations/0002_jmap_import.sql"),
     include_str!("../migrations/0003_pending_files.sql"),
+    include_str!("../migrations/0004_pending_deletes.sql"),
+    include_str!("../migrations/0005_pending_archives.sql"),
 ];
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -88,7 +90,7 @@ pub struct Store {
 #[derive(Debug)]
 struct Inner {
     conn: Connection,
-    undo: BTreeMap<String, (MessageId, Values)>,
+    undo: BTreeMap<String, (MessageId, Values, bool)>,
     flash: BTreeMap<String, Flash>,
 }
 
@@ -343,10 +345,20 @@ impl Store {
         };
         let prev = values;
         let mut next = prev.clone();
+        // Whether this edit queued the message's exit from the shared
+        // Inbox, so an undo can cancel it before the poll pushes it.
+        let mut queued_archive = false;
 
         let text = match change {
             Change::State(state) => {
                 next.state = state;
+                // Done means finished: on a writable account the mail
+                // also leaves the shared Inbox for Archive (labels and
+                // folders kept). Read-only accounts change state only.
+                if state == State::Done && !read_only {
+                    queue_archive(&tx, id)?;
+                    queued_archive = true;
+                }
                 format!("Moved to {state}")
             }
             Change::Folder(folder) => {
@@ -384,11 +396,19 @@ impl Store {
         write_values(&tx, id, &next)?;
         insert_event(&tx, id, user, self.now(), &text)?;
         tx.commit()?;
-        inner.undo.insert(user.to_owned(), (id, prev));
+        inner
+            .undo
+            .insert(user.to_owned(), (id, prev, queued_archive));
+        let flash = if queued_archive {
+            // The verb is Done; the archive is what it does.
+            "Done — leaves the shared Inbox".to_owned()
+        } else {
+            text
+        };
         inner.flash.insert(
             user.to_owned(),
             Flash {
-                text,
+                text: flash,
                 undoable: true,
             },
         );
@@ -397,12 +417,18 @@ impl Store {
 
     pub fn undo(&self, user: &str) -> Result<(), Error> {
         let mut inner = self.lock();
-        let (id, prev) = inner
+        let (id, prev, queued_archive) = inner
             .undo
             .get(user)
             .cloned()
             .ok_or(Error::BadRequest("nothing to undo"))?;
         let tx = inner.conn.transaction()?;
+        // An undone Done cancels its queued exit from the Inbox; if the
+        // poll already pushed it, the mail sits in Archive and the
+        // state simply reverts.
+        if queued_archive {
+            tx.execute("DELETE FROM pending_archives WHERE message = ?1", [id])?;
+        }
         // An undone filing reverts server-side too: the queued intent
         // points back at the folder the undo restores.
         let msg = load_message(&tx, id)?.ok_or(Error::NotFound("message"))?;
@@ -461,6 +487,116 @@ impl Store {
         Ok(())
     }
 
+    /// Deletes a message (task sm): queued for the poll loop to
+    /// destroy server-side — which lands it in Trash — while the
+    /// thread goes Done here with a history event. Any queued filing
+    /// or archive is superseded.
+    pub fn delete(&self, user: &str, id: MessageId) -> Result<(), Error> {
+        let mut inner = self.lock();
+        let tx = inner.conn.transaction()?;
+        let msg = load_message(&tx, id)?.ok_or(Error::NotFound("message"))?;
+        let read_only = load_thread_account(&tx, msg.thread)?.is_some_and(|a| a.read_only);
+        if read_only {
+            return Err(Error::Forbidden("this account is read-only"));
+        }
+        let prev = msg
+            .values()
+            .ok_or(Error::BadRequest("sent messages have no values"))?
+            .clone();
+        let mut next = prev;
+        next.state = State::Done;
+        tx.execute(
+            "INSERT INTO pending_deletes (message)
+             SELECT ?1 FROM messages WHERE id = ?1 AND jmap_id IS NOT NULL
+             ON CONFLICT (message) DO NOTHING",
+            [id],
+        )?;
+        tx.execute("DELETE FROM pending_files WHERE message = ?1", [id])?;
+        tx.execute("DELETE FROM pending_archives WHERE message = ?1", [id])?;
+        write_values(&tx, id, &next)?;
+        insert_event(&tx, id, user, self.now(), "Deleted")?;
+        tx.commit()?;
+        inner.flash.insert(
+            user.to_owned(),
+            Flash {
+                text: "Deleted".into(),
+                // The server-side trash can't be undone from here.
+                undoable: false,
+            },
+        );
+        Ok(())
+    }
+
+    /// The account's queued deletions, for the poll loop to push (task
+    /// sm).
+    pub fn pending_deletes(&self, account: &str) -> Result<Vec<PendingDelete>, Error> {
+        let inner = self.lock();
+        let mut stmt = inner.conn.prepare_cached(
+            "SELECT p.message, m.jmap_id
+             FROM pending_deletes p JOIN messages m ON m.id = p.message
+             WHERE m.account = ?1 ORDER BY p.message",
+        )?;
+        let pending = stmt
+            .query_map([account], |r| {
+                Ok(PendingDelete {
+                    message: r.get(0)?,
+                    jmap_id: r.get(1)?,
+                })
+            })?
+            .collect::<Result<_, _>>()?;
+        Ok(pending)
+    }
+
+    /// Drops queued deletions once the server has taken them — or
+    /// refused them, which no retry would fix.
+    pub fn clear_pending_deletes(&self, messages: &[MessageId]) -> Result<(), Error> {
+        if messages.is_empty() {
+            return Ok(());
+        }
+        let mut inner = self.lock();
+        let tx = inner.conn.transaction()?;
+        for id in messages {
+            tx.execute("DELETE FROM pending_deletes WHERE message = ?1", [id])?;
+        }
+        tx.commit()?;
+        Ok(())
+    }
+
+    /// The account's queued archives — the Done-is-finished policy —
+    /// for the poll loop to push.
+    pub fn pending_archives(&self, account: &str) -> Result<Vec<PendingArchive>, Error> {
+        let inner = self.lock();
+        let mut stmt = inner.conn.prepare_cached(
+            "SELECT p.message, m.jmap_id
+             FROM pending_archives p JOIN messages m ON m.id = p.message
+             WHERE m.account = ?1 ORDER BY p.message",
+        )?;
+        let pending = stmt
+            .query_map([account], |r| {
+                Ok(PendingArchive {
+                    message: r.get(0)?,
+                    jmap_id: r.get(1)?,
+                })
+            })?
+            .collect::<Result<_, _>>()?;
+        Ok(pending)
+    }
+
+    /// Drops queued archives once the server has taken them — or
+    /// refused them, or never needed them.
+    pub fn clear_pending_archives(&self, messages: &[MessageId]) -> Result<(), Error> {
+        if messages.is_empty() {
+            return Ok(());
+        }
+        let mut inner = self.lock();
+        let tx = inner.conn.transaction()?;
+        for id in messages {
+            tx.execute("DELETE FROM pending_archives WHERE message = ?1", [id])?;
+        }
+        tx.commit()?;
+        Ok(())
+    }
+
     pub fn take_flash(&self, user: &str) -> Option<Flash> {
         self.lock().flash.remove(user)
     }
@@ -534,6 +670,26 @@ pub struct PendingFile {
     pub folder: Option<String>,
 }
 
+/// One queued deletion, waiting for the poll loop to push it (task
+/// sm).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PendingDelete {
+    pub message: MessageId,
+    /// The JMAP Email id to destroy — cached on the message, read fresh
+    /// at drain time so a reimport's new id is the one that goes out.
+    pub jmap_id: String,
+}
+
+/// One queued archive, waiting for the poll loop to push it: Done's
+/// exit from the shared Inbox.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PendingArchive {
+    pub message: MessageId,
+    /// The JMAP Email id to move — cached on the message, read fresh at
+    /// drain time so a reimport's new id is the one that goes out.
+    pub jmap_id: String,
+}
+
 /// Writes inside one transaction, from [`Store::import`].
 pub struct Import<'a> {
     tx: Transaction<'a>,
@@ -889,6 +1045,19 @@ fn queue_file(conn: &Connection, id: MessageId, folder: Option<&str>) -> Result<
     Ok(())
 }
 
+/// Queues the message's exit from the shared Inbox (Done's meaning on
+/// a writable account). Fixture mail has no server side to leave, so
+/// rows without a cached JMAP id stay out.
+fn queue_archive(conn: &Connection, id: MessageId) -> Result<(), Error> {
+    conn.execute(
+        "INSERT INTO pending_archives (message)
+         SELECT ?1 FROM messages WHERE id = ?1 AND jmap_id IS NOT NULL
+         ON CONFLICT (message) DO NOTHING",
+        [id],
+    )?;
+    Ok(())
+}
+
 fn insert_event(
     conn: &Connection,
     message: MessageId,
@@ -1109,6 +1278,195 @@ mod tests {
         );
     }
 
+    #[test]
+    fn done_queues_the_exit_from_the_shared_inbox() {
+        let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
+        store
+            .import(|tx| {
+                tx.account(&Account {
+                    slug: "household".into(),
+                    name: "Household".into(),
+                    address: "household@example.com".into(),
+                    read_only: false,
+                })?;
+                tx.incoming("household", &incoming(State::Inbox, None, "$1,840"))
+            })
+            .unwrap();
+        let id = store
+            .messages(Filter::Search("1,840"))
+            .unwrap()
+            .first()
+            .unwrap()
+            .id;
+        store.sign_in(SAM, "Sam").unwrap();
+
+        // Done on a writable account queues the archive; the history
+        // names the verb, the flash teaches what it does.
+        store.edit(SAM, id, Change::State(State::Done)).unwrap();
+        assert_eq!(values(&store, id).state, State::Done);
+        assert_eq!(
+            store.pending_archives("household").unwrap(),
+            [PendingArchive {
+                message: id,
+                jmap_id: "E9".into(),
+            }]
+        );
+        assert_eq!(
+            store.take_flash(SAM).unwrap(),
+            Flash {
+                text: "Done — leaves the shared Inbox".into(),
+                undoable: true,
+            }
+        );
+        let history: Vec<_> = store
+            .history(id)
+            .unwrap()
+            .into_iter()
+            .map(|e| (e.user, e.text))
+            .collect();
+        assert_eq!(history, [(SAM.into(), "Moved to Done".into())]);
+
+        // Undo reverts the state and cancels the queued exit.
+        store.undo(SAM).unwrap();
+        assert_eq!(values(&store, id).state, State::Inbox);
+        assert!(store.pending_archives("household").unwrap().is_empty());
+
+        // Another account's drain never sees them; what the poll
+        // pushed, it clears.
+        store.edit(SAM, id, Change::State(State::Done)).unwrap();
+        assert!(store.pending_archives("eli").unwrap().is_empty());
+        store.clear_pending_archives(&[id]).unwrap();
+        assert!(store.pending_archives("household").unwrap().is_empty());
+        store.clear_pending_archives(&[]).unwrap();
+
+        // Deleting a Done message supersedes its queued archive.
+        store.edit(SAM, id, Change::State(State::Done)).unwrap();
+        store.delete(SAM, id).unwrap();
+        assert!(store.pending_archives("household").unwrap().is_empty());
+        assert_eq!(store.pending_deletes("household").unwrap().len(), 1);
+
+        // A Done that can't queue fails the whole edit, leaving the
+        // state alone.
+        exec(
+            &store,
+            "CREATE TRIGGER no_pending_archive BEFORE INSERT ON pending_archives BEGIN SELECT RAISE(ABORT, 'no'); END;",
+        );
+        store.edit(SAM, id, Change::State(State::Inbox)).unwrap();
+        assert!(matches!(
+            store.edit(SAM, id, Change::State(State::Done)),
+            Err(Error::Db(_))
+        ));
+        exec(&store, "DROP TRIGGER no_pending_archive;");
+        assert_eq!(values(&store, id).state, State::Inbox);
+    }
+
+    #[test]
+    fn done_without_a_server_side_stays_docket_local() {
+        // Read-only accounts and fixture mail mark Done without queueing
+        // anything.
+        let store = fixtures::store().unwrap();
+        let eli = fixtures::ELI_PRACTICE;
+        store.edit(SAM, eli, Change::State(State::Done)).unwrap();
+        assert!(store.pending_archives("eli").unwrap().is_empty());
+        assert_eq!(store.take_flash(SAM).unwrap().text, "Moved to Done");
+        store.edit(SAM, 4, Change::State(State::Done)).unwrap();
+        assert!(store.pending_archives("household").unwrap().is_empty());
+    }
+
+    #[test]
+    fn a_gone_pending_archives_table_fails_the_drain() {
+        // A fresh store, so the query compiles against the missing
+        // table rather than replaying a cached statement.
+        let store = fixtures::store().unwrap();
+        exec(&store, "DROP TABLE pending_archives;");
+        assert!(store.pending_archives("household").is_err());
+    }
+
+    #[test]
+    fn deleting_marks_done_and_queues_the_destroy() {
+        let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
+        store
+            .import(|tx| {
+                tx.account(&Account {
+                    slug: "household".into(),
+                    name: "Household".into(),
+                    address: "household@example.com".into(),
+                    read_only: false,
+                })?;
+                tx.folder("House")?;
+                tx.incoming("household", &incoming(State::Inbox, None, "$1,840"))
+            })
+            .unwrap();
+        let id = store
+            .messages(Filter::Search("1,840"))
+            .unwrap()
+            .first()
+            .unwrap()
+            .id;
+        store.sign_in(SAM, "Sam").unwrap();
+
+        // A queued filing is superseded: the destroy replaces it.
+        store
+            .edit(SAM, id, Change::Folder(Some("House".into())))
+            .unwrap();
+        store.delete(SAM, id).unwrap();
+        assert_eq!(values(&store, id).state, State::Done);
+        assert_eq!(
+            store.pending_deletes("household").unwrap(),
+            [PendingDelete {
+                message: id,
+                jmap_id: "E9".into(),
+            }]
+        );
+        assert!(store.pending_files("household").unwrap().is_empty());
+        assert_eq!(
+            store.take_flash(SAM),
+            Some(Flash {
+                text: "Deleted".into(),
+                undoable: false
+            })
+        );
+        let history: Vec<_> = store
+            .history(id)
+            .unwrap()
+            .into_iter()
+            .map(|e| (e.user, e.text))
+            .collect();
+        assert_eq!(
+            history,
+            [
+                (SAM.into(), "Filed to House".into()),
+                (SAM.into(), "Deleted".into())
+            ]
+        );
+
+        // Another account's drain never sees them; what the poll
+        // pushed, it clears.
+        assert!(store.pending_deletes("eli").unwrap().is_empty());
+        store.clear_pending_deletes(&[id]).unwrap();
+        assert!(store.pending_deletes("household").unwrap().is_empty());
+        store.clear_pending_deletes(&[]).unwrap();
+
+        // A deletion that can't queue fails, leaving the state alone.
+        exec(
+            &store,
+            "CREATE TRIGGER no_pending_delete BEFORE INSERT ON pending_deletes BEGIN SELECT RAISE(ABORT, 'no'); END;",
+        );
+        store.edit(SAM, id, Change::State(State::Inbox)).unwrap();
+        assert!(matches!(store.delete(SAM, id), Err(Error::Db(_))));
+        exec(&store, "DROP TRIGGER no_pending_delete;");
+        assert_eq!(values(&store, id).state, State::Inbox);
+    }
+
+    #[test]
+    fn a_gone_pending_deletes_table_fails_the_drain() {
+        // A fresh store, so the query compiles against the missing
+        // table rather than replaying a cached statement.
+        let store = fixtures::store().unwrap();
+        exec(&store, "DROP TABLE pending_deletes;");
+        assert!(store.pending_deletes("household").is_err());
+    }
+
     #[test]
     fn read_only_accounts_cannot_file() {
         let store = fixtures::store().unwrap();
@@ -1117,9 +1475,22 @@ mod tests {
             store.edit(SAM, eli, Change::Folder(None)),
             Err(Error::Forbidden(_))
         ));
+        assert!(matches!(store.delete(SAM, eli), Err(Error::Forbidden(_))));
         store.edit(SAM, eli, Change::State(State::Done)).unwrap();
     }
 
+    #[test]
+    fn fixture_and_sent_mail_delete_differently() {
+        let store = fixtures::store().unwrap();
+        // Fixture mail has no server side to trash, so no row queues —
+        // but the thread still goes Done.
+        store.delete(SAM, 4).unwrap();
+        assert_eq!(values(&store, 4).state, State::Done);
+        assert!(store.pending_deletes("household").unwrap().is_empty());
+        // Sent replies carry no values to transition.
+        assert!(matches!(store.delete(SAM, 2), Err(Error::BadRequest(_))));
+    }
+
     #[test]
     fn assignees_toggle() {
         let store = fixtures::store().unwrap();
diff --git a/src/views.rs b/src/views.rs
index 49d0e86..3c0f03d 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -454,6 +454,19 @@ fn values_controls(p: &Page<'_>, id: u32, values: &Values, read_only: bool) -> M
                 None => span.type-label.value.unset { "Assign" },
             }
         }, format!("/messages/{id}/assignees"), p.here, people))
+        @if !read_only {
+            button.del-btn type="button" aria-haspopup="dialog"
+                onclick=(format!("document.getElementById('trash-{}').showModal()", id)) { "Trash" }
+            dialog id=(format!("trash-{}", id)) {
+                h3 { "Move to Trash?" }
+                p { "The thread goes Done in Docket; the mail lands in Fastmail's Trash." }
+                form method="post" action=(format!("/messages/{id}/delete")) {
+                    (back(p.here))
+                    button.btn type="submit" formmethod="dialog" autofocus { "Cancel" }
+                    button.btn.danger type="submit" { "Trash" }
+                }
+            }
+        }
     }
 }
 
diff --git a/tests/api.rs b/tests/api.rs
index 64f8669..c859751 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -478,3 +478,28 @@ async fn serves_assets() {
         assert!(res.text().await.unwrap().contains(needle));
     }
 }
+
+#[tokio::test]
+async fn trashing_confirms_through_a_dialog_and_marks_done() {
+    let addr = spawn().await;
+    // The trash affordance is a dialog on writable accounts: open it
+    // with showModal, cancel closes without posting, and the form
+    // targets the delete route.
+    let (_, body) = get(addr, SAM, "/inbox?m=8").await;
+    assert!(body.contains(r#"id="trash-8""#));
+    assert!(body.contains("showModal"));
+    assert!(body.contains("Move to Trash?"));
+    assert!(body.contains(r#"formmethod="dialog""#));
+    assert!(body.contains(r#"action="/messages/8/delete""#));
+
+    let res = post(addr, SAM, "/messages/8/delete", &[("back", "/inbox?m=8")]).await;
+    assert_eq!(res.status(), StatusCode::SEE_OTHER);
+    let (_, body) = get(addr, SAM, "/search?q=142.18&m=8").await;
+    assert!(body.contains(r#"<span class="badge success"><span class="mark"></span>Done</span>"#));
+
+    // Read-only accounts get neither the dialog nor the route.
+    let (_, body) = get(addr, SAM, "/watch?m=20").await;
+    assert!(!body.contains(r#"id="trash-20""#));
+    let res = post(addr, SAM, "/messages/20/delete", &[("back", "/watch?m=20")]).await;
+    assert_eq!(res.status(), StatusCode::FORBIDDEN);
+}
diff --git a/tests/jmap.rs b/tests/jmap.rs
index 7cd52c0..9b9994e 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -112,6 +112,7 @@ fn mailboxes() -> Vec<Value> {
     [
         ("MB-in", "Inbox", Some("inbox"), &rights),
         ("MB-sent", "Sent", Some("sent"), &rights),
+        ("MB-archive", "Archive", Some("archive"), &rights),
         ("MB-drafts", "Drafts", Some("drafts"), &rights),
         ("MB-sched", "Scheduled", Some("scheduled"), &restricted),
         ("MB-do", "Docket/Do", None, &rights),
@@ -486,8 +487,36 @@ async fn api(world: axum::extract::State<World>, headers: HeaderMap, body: Strin
     let method = call[0].as_str().expect("a method name").to_owned();
     let args = &call[1];
 
-    // The one mutating method, so it locks for itself and answers early.
-    if method == "Email/set" {
+    // The mutating methods, so they lock for themselves and answer
+    // early.
+    if method == "Email/set" || method == "Email/destroy" {
+        if method == "Email/destroy" {
+            let mut stub = world.lock().unwrap();
+            let mut destroyed = Vec::new();
+            let mut not_destroyed = serde_json::Map::new();
+            for id in args["destroy"].as_array().expect("an id list") {
+                let id = id.as_str().expect("an id");
+                if stub.refuse.contains(id) {
+                    not_destroyed.insert(
+                        id.to_owned(),
+                        json!({"type": "serverFail", "description": "refused"}),
+                    );
+                    continue;
+                }
+                let mail = stub.accounts.get_mut(account).unwrap();
+                if mail.emails.iter().any(|e| e["id"] == json!(id)) {
+                    mail.emails.retain(|e| e["id"] != json!(id));
+                    destroyed.push(id.to_owned());
+                } else {
+                    not_destroyed.insert(id.to_owned(), json!({"type": "notFound"}));
+                }
+            }
+            stub.snapshot(account);
+            let reply = json!({"accountId": account, "oldState": "s", "newState": "s",
+                               "destroyed": destroyed, "notDestroyed": not_destroyed});
+            return Json(json!({"methodResponses": [["Email/destroy", reply, "0"]]}))
+                .into_response();
+        }
         let mut stub = world.lock().unwrap();
         let mut updated = serde_json::Map::new();
         let mut not_updated = serde_json::Map::new();
@@ -1346,3 +1375,257 @@ async fn a_filing_into_a_vanished_folder_drops() {
     assert_eq!(counts.filed, 0, "{counts:?}");
     assert!(store.pending_files("household").unwrap().is_empty());
 }
+
+#[tokio::test]
+async fn done_archives_out_of_the_shared_inbox() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    let school = school_message(&store);
+    store
+        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .unwrap();
+    assert_eq!(store.pending_archives("household").unwrap().len(), 1);
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    assert_eq!(counts.archived, 1, "{counts:?}");
+
+    // Server side: out of the Inbox, into Archive, label and folder
+    // kept.
+    let memberships = {
+        let world = world.lock().unwrap();
+        world.accounts[HOUSEHOLD]
+            .emails
+            .iter()
+            .find(|e| e["id"] == json!("E-school"))
+            .unwrap()["mailboxIds"]
+            .clone()
+    };
+    assert_eq!(
+        memberships,
+        json!({"MB-watch": true, "MB-school": true, "MB-archive": true})
+    );
+    assert!(store.pending_archives("household").unwrap().is_empty());
+    assert_eq!(
+        store
+            .message(school.id)
+            .unwrap()
+            .unwrap()
+            .values()
+            .unwrap()
+            .state,
+        State::Done
+    );
+}
+
+#[tokio::test]
+async fn mail_already_out_of_the_inbox_exits_nowhere() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    // Another client files the school mail out of the Inbox before the
+    // Done lands.
+    world
+        .lock()
+        .unwrap()
+        .update_email(HOUSEHOLD, "E-school", |mail| {
+            mail["mailboxIds"] = json!({"MB-school": true});
+        });
+    let school = school_message(&store);
+    store
+        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .unwrap();
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    // No exit to make: the intent clears and nothing moves.
+    assert_eq!(counts.archived, 0, "{counts:?}");
+    assert!(store.pending_archives("household").unwrap().is_empty());
+    let memberships = {
+        let world = world.lock().unwrap();
+        world.accounts[HOUSEHOLD]
+            .emails
+            .iter()
+            .find(|e| e["id"] == json!("E-school"))
+            .unwrap()["mailboxIds"]
+            .clone()
+    };
+    assert_eq!(memberships, json!({"MB-school": true}));
+}
+
+#[tokio::test]
+async fn acl_denied_archives_wait_for_the_rights() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    world
+        .lock()
+        .unwrap()
+        .update_mailbox(HOUSEHOLD, "MB-archive", |m| {
+            m["myRights"]["mayAddItems"] = json!(false);
+        });
+    let school = school_message(&store);
+    store
+        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .unwrap();
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+    assert_eq!(counts.archived, 0, "{counts:?}");
+    assert_eq!(store.pending_archives("household").unwrap().len(), 1);
+
+    // The rights come back and the waiting exit goes through.
+    world
+        .lock()
+        .unwrap()
+        .update_mailbox(HOUSEHOLD, "MB-archive", |m| {
+            m["myRights"]["mayAddItems"] = json!(true);
+        });
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+    assert_eq!(counts.archived, 1, "{counts:?}");
+    assert!(store.pending_archives("household").unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn a_refused_archive_clears_rather_than_retries() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    let school = school_message(&store);
+    store
+        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .unwrap();
+    world.lock().unwrap().refuse.insert("E-school".into());
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    assert_eq!(counts.archived, 0, "{counts:?}");
+    assert!(store.pending_archives("household").unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn an_archive_for_mail_thats_gone_drops() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    let school = school_message(&store);
+    store
+        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .unwrap();
+    world.lock().unwrap().remove_email(HOUSEHOLD, "E-school");
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    assert_eq!(counts.archived, 0, "{counts:?}");
+    assert!(store.pending_archives("household").unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn an_archive_with_nowhere_to_land_drops() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    let school = school_message(&store);
+    store
+        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .unwrap();
+    // The server loses its Archive mailbox before the exit lands.
+    world
+        .lock()
+        .unwrap()
+        .remove_mailbox(HOUSEHOLD, "MB-archive");
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    assert_eq!(counts.archived, 0, "{counts:?}");
+    assert!(store.pending_archives("household").unwrap().is_empty());
+    let memberships = {
+        let world = world.lock().unwrap();
+        world.accounts[HOUSEHOLD]
+            .emails
+            .iter()
+            .find(|e| e["id"] == json!("E-school"))
+            .unwrap()["mailboxIds"]
+            .clone()
+    };
+    assert_eq!(
+        memberships,
+        json!({"MB-in": true, "MB-watch": true, "MB-school": true})
+    );
+}
+
+#[tokio::test]
+async fn trashing_destroys_through_the_poll() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    let school = school_message(&store);
+    store.delete("sam@example.com", school.id).unwrap();
+    assert_eq!(store.pending_deletes("household").unwrap().len(), 1);
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    assert_eq!(counts.deleted, 1, "{counts:?}");
+    let gone = {
+        let world = world.lock().unwrap();
+        !world.accounts[HOUSEHOLD]
+            .emails
+            .iter()
+            .any(|e| e["id"] == json!("E-school"))
+    };
+    assert!(gone);
+    assert!(store.pending_deletes("household").unwrap().is_empty());
+    // The cached row stays, Done.
+    assert_eq!(
+        store
+            .message(school.id)
+            .unwrap()
+            .unwrap()
+            .values()
+            .unwrap()
+            .state,
+        State::Done
+    );
+}
+
+#[tokio::test]
+async fn a_refused_destruction_clears_rather_than_retries() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    let school = school_message(&store);
+    store.delete("sam@example.com", school.id).unwrap();
+    world.lock().unwrap().refuse.insert("E-school".into());
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    assert_eq!(counts.deleted, 0, "{counts:?}");
+    assert!(store.pending_deletes("household").unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn a_destruction_for_mail_thats_gone_clears() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    let school = school_message(&store);
+    store.delete("sam@example.com", school.id).unwrap();
+    world.lock().unwrap().remove_email(HOUSEHOLD, "E-school");
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    assert_eq!(counts.deleted, 0, "{counts:?}");
+    assert!(store.pending_deletes("household").unwrap().is_empty());
+}