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
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());
+}