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
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