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