jmap: accept null for absent mail headers
RFC 8621 §4.1 lets from/to/cc/bcc, messageId, and sentAt come back
null — the fixtures never sent null, so the struct demanded what real
mail omits. A missing sentAt falls back to receivedAt, and a rejected
token now says so instead of dumping the reqwest error.

Assisted-by: GLM-5.3 via pi
change suplxnkszzopqwnkpnnvvzpusrlkmouq
commit 8a2e7c46a24e80e93de86ec91244e3a1f5441578
author Alpha Chen <alpha@kejadlen.dev>
date
parent yxooopxv
diff --git a/src/jmap.rs b/src/jmap.rs
index e3b6d1a..f7860b8 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -61,6 +61,9 @@ pub enum JmapError {
 
     #[error("credential {credential:?} offers no mail account in its session")]
     NoMailAccount { credential: String },
+
+    #[error("the token was rejected (401): wrong or revoked for this account")]
+    Unauthorized,
 }
 
 /// A token's session, as the server reports it (RFC 8620 §3). Only what
@@ -312,17 +315,21 @@ struct BodyValue {
 struct Email {
     pub id: String,
     thread_id: String,
-    /// The Message-ID header, without angle brackets.
-    pub message_id: String,
+    /// The Message-ID headers, without angle brackets (RFC 8621 §4.1:
+    /// `String[]|null` — absent or repeated headers).
+    pub message_id: Option<Vec<String>>,
     pub mailbox_ids: BTreeMap<String, bool>,
     pub received_at: Timestamp,
-    pub sent_at: Timestamp,
+    /// `Date|null`: mail can carry no Date header, so it falls back to
+    /// receivedAt at the call site.
+    pub sent_at: Option<Timestamp>,
     pub subject: Option<String>,
     pub preview: Option<String>,
-    pub from: Vec<EmailAddress>,
-    pub to: Vec<EmailAddress>,
-    pub cc: Vec<EmailAddress>,
-    pub bcc: Vec<EmailAddress>,
+    /// All null when the header is absent (RFC 8621 §4.1).
+    pub from: Option<Vec<EmailAddress>>,
+    pub to: Option<Vec<EmailAddress>>,
+    pub cc: Option<Vec<EmailAddress>>,
+    pub bcc: Option<Vec<EmailAddress>>,
     pub text_body: Option<Vec<BodyPart>>,
     pub body_values: Option<BTreeMap<String, BodyValue>>,
 }
@@ -348,21 +355,24 @@ fn body_of(email: &Email) -> String {
 fn incoming(email: &Email, layout: &Layout, users: &[User]) -> Option<Incoming> {
     let civil = |at: &Timestamp| at.to_zoned(jiff::tz::TimeZone::UTC).datetime();
     let names = |xs: &[EmailAddress]| xs.iter().map(EmailAddress::display).collect::<Vec<_>>();
+    fn empty(xs: &Option<Vec<EmailAddress>>) -> &[EmailAddress] {
+        xs.as_deref().unwrap_or_default()
+    }
     let sent = layout.is_sent(email);
     let kind = if sent {
         IncomingKind::Sent {
             // Attribution by From address; the shared identity matches
             // no login and stays None.
-            by: email.from.iter().find_map(|a| {
+            by: empty(&email.from).iter().find_map(|a| {
                 users
                     .iter()
                     .find(|u| u.login == a.email)
                     .map(|u| u.login.clone())
             }),
-            to: names(&email.to),
+            to: names(empty(&email.to)),
         }
     } else {
-        let from = email.from.first()?;
+        let from = empty(&email.from).first()?;
         IncomingKind::Received {
             from: from.display(),
             addr: from.email.clone(),
@@ -372,19 +382,24 @@ fn incoming(email: &Email, layout: &Layout, users: &[User]) -> Option<Incoming>
     };
     Some(Incoming {
         jmap_id: email.id.clone(),
-        message_id: email.message_id.clone(),
+        message_id: email
+            .message_id
+            .as_deref()
+            .and_then(|ids| ids.first())
+            .cloned()
+            .unwrap_or_default(),
         jmap_thread_id: email.thread_id.clone(),
         subject: email
             .subject
             .clone()
             .unwrap_or_else(|| "(no subject)".into()),
         at: civil(if sent {
-            &email.sent_at
+            email.sent_at.as_ref().unwrap_or(&email.received_at)
         } else {
             &email.received_at
         }),
-        cc: names(&email.cc),
-        bcc: names(&email.bcc),
+        cc: names(empty(&email.cc)),
+        bcc: names(empty(&email.bcc)),
         body: body_of(email),
         kind,
     })
@@ -703,15 +718,16 @@ impl Client {
     }
 
     async fn session(&self, token: &str) -> Result<Session, JmapError> {
-        let body = self
+        let response = self
             .http
             .get(&self.session_url)
             .bearer_auth(token)
             .send()
-            .await?
-            .error_for_status()?
-            .text()
             .await?;
+        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
+            return Err(JmapError::Unauthorized);
+        }
+        let body = response.error_for_status()?.text().await?;
         Session::parse(&body)
     }
 
@@ -1581,7 +1597,7 @@ mod tests {
         let json = json!({
             "id": "E1",
             "threadId": "T1",
-            "messageId": "msg-1@chislan.family",
+            "messageId": ["msg-1@chislan.family"],
             "mailboxIds": BTreeMap::from_iter(
                 mailboxes.iter().map(|(id, member)| (id.to_string(), json!(member)))
             ),
@@ -1739,25 +1755,25 @@ mod tests {
         // A sent reply attributes by From address; the shared identity
         // matches no login.
         let mut mine = email(&[("M-sent", true)]);
-        mine.from = vec![EmailAddress {
+        mine.from = Some(vec![EmailAddress {
             name: Some("Sam".into()),
             email: "sam@example.com".into(),
-        }];
+        }]);
         assert!(matches!(&incoming(&mine, &layout, &users).unwrap().kind,
             IncomingKind::Sent { by, to }
             if by.as_deref() == Some("sam@example.com")
                 && to == &["household@example.com".to_string()]));
 
-        mine.from = vec![EmailAddress {
+        mine.from = Some(vec![EmailAddress {
             name: None,
             email: "household@example.com".into(),
-        }];
+        }]);
         assert!(matches!(&incoming(&mine, &layout, &users).unwrap().kind,
             IncomingKind::Sent { by, .. } if by.is_none()));
 
         // Nothing to show for a senderless oddity.
         let mut odd = email(&[("M-in", true)]);
-        odd.from = Vec::new();
+        odd.from = None;
         assert!(incoming(&odd, &layout, &users).is_none());
     }
 
diff --git a/tests/jmap.rs b/tests/jmap.rs
index 75b3848..78d1dee 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -142,7 +142,7 @@ fn email_json(
     let mut mail = json!({
         "id": id,
         "threadId": thread,
-        "messageId": format!("{id}@chislan.family"),
+        "messageId": [format!("{id}@chislan.family")],
         "mailboxIds": mailbox_ids(in_mailboxes),
         "receivedAt": "2026-10-02T12:00:00Z",
         "sentAt": "2026-10-02T11:00:00Z",
@@ -192,16 +192,24 @@ fn emails_of(account: &str) -> Vec<Value> {
                 Some("Revised estimate: $2,120."),
                 "Revised estimate",
             ),
-            email_json(
-                "E-school",
-                "T-school",
-                ("Lincoln High School", "office@lincolnhigh.org"),
-                "household",
-                &["MB-in", "MB-watch", "MB-school"],
-                "Field trip",
-                None,
-                "Buses now return at 4:15.",
-            ),
+            // Real servers answer null for absent headers (RFC 8621
+            // §4.1): this one has no Cc, Bcc, or Message-Id.
+            {
+                let mut school = email_json(
+                    "E-school",
+                    "T-school",
+                    ("Lincoln High School", "office@lincolnhigh.org"),
+                    "household",
+                    &["MB-in", "MB-watch", "MB-school"],
+                    "Field trip",
+                    None,
+                    "Buses now return at 4:15.",
+                );
+                school["cc"] = Value::Null;
+                school["bcc"] = Value::Null;
+                school["messageId"] = Value::Null;
+                school
+            },
             email_json(
                 "E-reply1",
                 "T-roofer",
@@ -212,16 +220,22 @@ fn emails_of(account: &str) -> Vec<Value> {
                 Some("Does this include the downspout?"),
                 "Does this include",
             ),
-            email_json(
-                "E-reply2",
-                "T-school",
-                ("Household", HOUSEHOLD),
-                "office@lincolnhigh.org",
-                &["MB-sent"],
-                "Re: Field trip",
-                Some("Eli has pickup that day."),
-                "Eli has pickup",
-            ),
+            // Sent mail with no Date header: sentAt comes back null
+            // and falls back to receivedAt.
+            {
+                let mut reply2 = email_json(
+                    "E-reply2",
+                    "T-school",
+                    ("Household", HOUSEHOLD),
+                    "office@lincolnhigh.org",
+                    &["MB-sent"],
+                    "Re: Field trip",
+                    Some("Eli has pickup that day."),
+                    "Eli has pickup",
+                );
+                reply2["sentAt"] = Value::Null;
+                reply2
+            },
             email_json(
                 "E-reply3",
                 "T-ancient",
@@ -244,7 +258,7 @@ fn emails_of(account: &str) -> Vec<Value> {
                     None,
                     "",
                 );
-                noise["from"] = json!([]);
+                noise["from"] = Value::Null;
                 noise
             },
             // A draft in an Inbox thread: fetched with the thread, kept
@@ -648,7 +662,11 @@ async fn each_credential_opens_a_session_and_imports_its_mail() {
     assert!(roofer1.values().unwrap().assignees.is_empty());
     assert_eq!(roofer1.body, "Estimate attached: $1,840.");
     assert_eq!(by_id("E-roofer2").values().unwrap().state, State::Do);
-    let school = by_id("E-school");
+    // Its Message-Id is null in the fixture, so find it by body.
+    let school = all
+        .iter()
+        .find(|m| m.body == "Buses now return at 4:15.")
+        .unwrap_or_else(|| panic!("missing school"));
     assert_eq!(school.values().unwrap().state, State::Watch);
     assert_eq!(school.values().unwrap().folder.as_deref(), Some("School"));
     // HTML-only mail keeps the preview as its body.
@@ -872,7 +890,8 @@ async fn destroyed_mail_stays_cached() {
     assert_eq!(counts.destroyed, 1, "{counts:?}");
     assert_eq!(counts.imported, 0, "{counts:?}");
     assert!(!counts.is_quiet());
-    assert!(the(&store, "E-school@chislan.family").is_some());
+    // School's Message-Id is null in the fixture, so it files under "".
+    assert!(the(&store, "").is_some());
 }
 
 #[tokio::test]
@@ -891,7 +910,7 @@ async fn a_reimported_message_matches_by_message_id() {
         Some("Estimate attached: $1,840."),
         "Estimate attached",
     );
-    again["messageId"] = json!("E-roofer1@chislan.family");
+    again["messageId"] = json!(["E-roofer1@chislan.family"]);
     world.lock().unwrap().add_email(HOUSEHOLD, again);
 
     let counts = client