jmap: push deletions through Email/set
JMAP has no Email/destroy method; destroying is the destroy argument
of Email/set (RFC 8620 §5.3). Fastmail answered unknownMethod, and
since deletions are pushed before Email/changes, one queued deletion
failed every poll after it, so the account stopped syncing. The test
stub accepted Email/destroy too, which is why the tests never caught
it. The stub now only takes Email/set and panics on any other method,
so the tests will catch this mistake from now on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change oqqsxnlvvxpwtmlnqxvmlxwwxqurwuwx
commit 54012cd8c2e6d9d99838b72b45a271db84277e7c
author Alpha Chen <alpha@kejadlen.dev>
date
parent mnrlwtkt
diff --git a/src/jmap.rs b/src/jmap.rs
index f6c920a..7ee133c 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -7,9 +7,8 @@
 //! 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 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.
+//! and deletions (tasks rn and sm) with `Email/set`. Push replaces the
+//! timer later (task nwylszul); the changes application stays.
 
 use std::collections::{BTreeMap, BTreeSet};
 use std::fmt;
@@ -720,10 +719,10 @@ fn parse_set_emails(args: Value) -> Result<SetEmailsReply, JmapError> {
     })
 }
 
-/// Parses an `Email/destroy` reply's verdict on each id.
+/// Parses an `Email/set` 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",
+        what: "Email/set",
         source,
     })
 }
@@ -961,7 +960,8 @@ struct SetEmailsReply {
     not_updated: BTreeMap<String, Value>,
 }
 
-/// `Email/destroy` arguments (RFC 8621 §5.5).
+/// `Email/set` destroy arguments (RFC 8620 §5.3; JMAP has no
+/// `Email/destroy` method).
 #[derive(Debug, Serialize)]
 #[serde(rename_all = "camelCase")]
 struct DestroyEmails<'a> {
@@ -969,7 +969,7 @@ struct DestroyEmails<'a> {
     destroy: &'a [String],
 }
 
-/// The outcome half of an `Email/destroy` reply: which ids went, and
+/// The outcome half of an `Email/set` destroy reply: which ids went, and
 /// which the server refused.
 #[derive(Debug, Deserialize)]
 #[serde(rename_all = "camelCase")]
@@ -1146,7 +1146,7 @@ impl Client {
         parse_set_emails(args)
     }
 
-    /// Destroys emails with one `Email/destroy`, returning the
+    /// Destroys emails with one `Email/set`, returning the
     /// server's verdict on each id.
     async fn destroy_emails(
         &self,
@@ -1159,7 +1159,7 @@ impl Client {
             .call(
                 api_url,
                 token,
-                "Email/destroy",
+                "Email/set",
                 &DestroyEmails {
                     account_id,
                     destroy,
@@ -1669,7 +1669,7 @@ impl Client {
     }
 
     /// Drains the account's queued deletions (task sm) with
-    /// `Email/destroy` — Fastmail's answer to a destroy is moving the
+    /// `Email/set` destroys — 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.
@@ -2312,7 +2312,7 @@ mod tests {
     #[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}");
+        assert!(err.to_string().contains("malformed Email/set"), "{err}");
     }
 
     #[test]
diff --git a/tests/jmap.rs b/tests/jmap.rs
index 8e126e8..ecb0f20 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -488,10 +488,11 @@ 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 mutating methods, so they lock for themselves and answer
-    // early.
-    if method == "Email/set" || method == "Email/destroy" {
-        if method == "Email/destroy" {
+    // The mutating method, so it locks for itself and answers early.
+    if method == "Email/set" {
+        // One method for both: a destroy carries `destroy`, a patch
+        // `update` (RFC 8620 §5.3).
+        if args.get("destroy").is_some() {
             let mut stub = world.lock().unwrap();
             let mut destroyed = Vec::new();
             let mut not_destroyed = serde_json::Map::new();
@@ -515,8 +516,7 @@ async fn api(world: axum::extract::State<World>, headers: HeaderMap, body: Strin
             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();
+            return Json(json!({"methodResponses": [["Email/set", reply, "0"]]})).into_response();
         }
         let mut stub = world.lock().unwrap();
         let mut updated = serde_json::Map::new();