From 5e32fe842a1f11544410af203193fc8fbebd7951 Mon Sep 17 00:00:00 2001 From: anonsaber Date: Tue, 29 Sep 2026 21:48:04 +0800 Subject: [PATCH] fix(jmap): request the From property so notifications show the sender read_email never asked Stalwart for `From`, so `EmailMetadata.sender` came back empty for every message and the Telegram line rendered as `From: unknown`. List page was unaffected: fetch_email_page passes no `properties` argument and therefore receives the full default set, which already includes `from`. sender is now formatted as the standard mailbox form - `Name ` when the display name exists, the bare address otherwise - so a human-readable name is shown instead of a raw local part. --- src/domain/jmap/client.rs | 76 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/src/domain/jmap/client.rs b/src/domain/jmap/client.rs index 4972003..7961210 100644 --- a/src/domain/jmap/client.rs +++ b/src/domain/jmap/client.rs @@ -286,6 +286,7 @@ impl JmapBackend for JmapClientBackend { } let mut request = self.client.build(); let get = request.get_email().ids([email_id]).properties([ + email::Property::From, email::Property::Subject, email::Property::Preview, email::Property::TextBody, @@ -488,6 +489,15 @@ fn select_account<'a>( Ok(selected.to_owned()) } +/// One from-address the way RFC 5322 prints it, which is what a reader expects +/// on a `From:` line. With no display name, the address is the whole thing. +fn sender_display(address: &jmap_client::email::EmailAddress) -> String { + match address.name() { + Some(name) => format!("{name} <{}>", address.email()), + None => address.email().to_owned(), + } +} + fn metadata(email: &jmap_client::email::Email) -> EmailMetadata { EmailMetadata { id: email.id().unwrap_or_default().to_owned(), @@ -495,7 +505,7 @@ fn metadata(email: &jmap_client::email::Email) -> EmailMetadata { sender: email .from() .and_then(|addresses| addresses.first()) - .map(|address| address.email().to_owned()), + .map(sender_display), received_at: email.received_at(), preview: email.preview().map(str::to_owned), size: Some(email.size()), @@ -638,4 +648,68 @@ mod tests { assert_eq!(role_name(mailbox::Role::Inbox).as_deref(), Some("inbox")); assert_eq!(role_name(mailbox::Role::None), None); } + + fn email_with_from(addresses: &[serde_json::Value]) -> email::Email { + serde_json::from_value(serde_json::json!({ + "id": "m1", + "subject": "hello", + "preview": "hi", + "from": addresses, + })) + .expect("a minimal Email/get payload must deserialize") + } + + #[test] + fn sender_keeps_the_display_name_in_rfc5322_form() { + let address = serde_json::from_value::(serde_json::json!({ + "email": "zhang@example.com", + "name": "张三", + })) + .expect("a minimal from-address must deserialize"); + + assert_eq!(sender_display(&address), "张三 "); + } + + #[test] + fn sender_without_a_display_name_is_the_bare_address() { + let address = serde_json::from_value::(serde_json::json!({ + "email": "noreply@example.com", + })) + .expect("a nameless from-address must deserialize"); + + assert_eq!(sender_display(&address), "noreply@example.com"); + } + + #[test] + fn metadata_takes_the_sender_from_the_from_address() { + // The address is only readable if Email/get actually asked for the "from" + // property, which is why `metadata` must read it from `from` and not just + // keep the bare address. + let email = + email_with_from(&[serde_json::json!({"email": "zhang@example.com", "name": "张三"})]); + let meta = metadata(&email); + assert_eq!(meta.sender.as_deref(), Some("张三 ")); + + // No display name, so the address stands alone. + let email = email_with_from(&[serde_json::json!({"email": "noreply@example.com"})]); + assert_eq!( + metadata(&email).sender.as_deref(), + Some("noreply@example.com") + ); + + // Multiple addresses: the notification shows the first one. + let email = email_with_from(&[ + serde_json::json!({"email": "first@example.com"}), + serde_json::json!({"email": "second@example.com"}), + ]); + assert_eq!( + metadata(&email).sender.as_deref(), + Some("first@example.com") + ); + + // No `from` at all means `sender` stays `None` and the caller supplies + // its fallback, so this assertion would also hold before the fix. + let email = email_with_from(&[]); + assert_eq!(metadata(&email).sender, None); + } }