diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 4fbf7995..5e746ef5 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -916,6 +916,11 @@ pub struct MessageMetadata { pub text_html_source: Option, pub calendar: Option, pub raw_headers: Option, + /// Addresses from the `Reply-To:` header, if the sender set one. + /// Replies target these instead of `From:` (mailing lists and + /// no-reply senders rely on it). Empty when absent. + #[serde(default)] + pub reply_to: Vec
, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/daemon/src/handler/mutations.rs b/crates/daemon/src/handler/mutations.rs index 36d7f60d..df958b46 100644 --- a/crates/daemon/src/handler/mutations.rs +++ b/crates/daemon/src/handler/mutations.rs @@ -1789,15 +1789,23 @@ pub(super) async fn prepare_reply( .map(|account| account.email) .unwrap_or_default(); - let thread_context = match state.sync_engine.get_body(message_id).await { - Ok(body) => (*get_or_render_reply_context(state, message_id, &body)).clone(), - Err(_) => String::new(), + let body = state.sync_engine.get_body(message_id).await.ok(); + let thread_context = match body.as_ref() { + Some(body) => (*get_or_render_reply_context(state, message_id, body)).clone(), + None => String::new(), }; let self_address = from.to_ascii_lowercase(); - // Envelope does not yet capture the Reply-To: header — using From: as the reply target - // covers the common case. Capturing Reply-To: properly is a post-v1 envelope schema change. - let reply_to = envelope.from.email.clone(); + // Prefer the Reply-To: header when the sender set one — mailing lists + // and no-reply senders depend on it — falling back to From: otherwise. + // Reply-To is captured into the body metadata at parse time. + let reply_to = body + .as_ref() + .and_then(|body| body.metadata.reply_to.first()) + .map_or_else( + || envelope.from.email.clone(), + |address| address.email.clone(), + ); let cc = if reply_all { let mut seen: std::collections::HashSet = std::collections::HashSet::new(); diff --git a/crates/daemon/src/handler/tests/mutations_and_delivery.rs b/crates/daemon/src/handler/tests/mutations_and_delivery.rs index 30f9548e..279e2c0e 100644 --- a/crates/daemon/src/handler/tests/mutations_and_delivery.rs +++ b/crates/daemon/src/handler/tests/mutations_and_delivery.rs @@ -836,6 +836,53 @@ async fn dispatch_prepare_reply_renders_html_context() { } } +#[tokio::test] +async fn dispatch_prepare_reply_prefers_reply_to_header() { + let state = Arc::new(AppState::in_memory().await.unwrap()); + let id = sync_and_get_first_id(&state).await; + + // The original message carries a Reply-To distinct from its From + // (the mailing-list / no-reply case). + state + .store + .insert_body(&mxr_core::types::MessageBody { + message_id: id.clone(), + text_plain: Some("digest".into()), + text_html: None, + attachments: vec![], + fetched_at: chrono::Utc::now(), + metadata: mxr_core::types::MessageMetadata { + reply_to: vec![mxr_core::types::Address { + name: Some("List Discussion".into()), + email: "discuss@list.example.com".into(), + }], + ..Default::default() + }, + }) + .await + .unwrap(); + + let msg = IpcMessage { + id: 2, + source: ::mxr_protocol::ClientKind::default(), + payload: IpcPayload::Request(Request::PrepareReply { + message_id: id, + reply_all: false, + }), + }; + match handle_request(&state, &msg).await.payload { + IpcPayload::Response(Response::Ok { + data: ResponseData::ReplyContext { context }, + }) => { + assert_eq!( + context.reply_to, "discuss@list.example.com", + "reply must target the Reply-To header, not From" + ); + } + other => panic!("Expected ReplyContext, got {other:?}"), + } +} + #[tokio::test] async fn dispatch_prepare_forward() { let state = Arc::new(AppState::in_memory().await.unwrap()); diff --git a/crates/mail-parse/src/lib.rs b/crates/mail-parse/src/lib.rs index 631699e5..5b337bd2 100644 --- a/crates/mail-parse/src/lib.rs +++ b/crates/mail-parse/src/lib.rs @@ -446,6 +446,8 @@ fn extract_metadata(message: &Message<'_>, raw_headers: Option) -> Messa .map(std::string::ToString::to_string); let text_plain_format = message.content_type().and_then(parse_text_plain_format); + let reply_to = message.reply_to().map(extract_addrs).unwrap_or_default(); + MessageMetadata { list_id, auth_results, @@ -455,6 +457,7 @@ fn extract_metadata(message: &Message<'_>, raw_headers: Option) -> Messa text_html_source: None, calendar: None, raw_headers, + reply_to, } } @@ -777,6 +780,39 @@ mod tests { ); } + #[test] + fn reply_to_header_is_captured_into_metadata() { + let raw = concat!( + "From: No Reply \r\n", + "Reply-To: List Discussion \r\n", + "To: me@example.com\r\n", + "Subject: Weekly digest\r\n", + "Message-ID: \r\n", + "\r\n", + "Body.\r\n", + ); + let metadata = parse_message_metadata_from_raw(raw.as_bytes()).unwrap(); + assert_eq!(metadata.reply_to.len(), 1); + assert_eq!(metadata.reply_to[0].email, "discuss@list.example.com"); + assert_eq!( + metadata.reply_to[0].name.as_deref(), + Some("List Discussion") + ); + } + + #[test] + fn absent_reply_to_leaves_metadata_empty() { + let raw = concat!( + "From: Alice \r\n", + "To: me@example.com\r\n", + "Subject: Hi\r\n", + "\r\n", + "Body.\r\n", + ); + let metadata = parse_message_metadata_from_raw(raw.as_bytes()).unwrap(); + assert!(metadata.reply_to.is_empty()); + } + #[test] fn calendar_metadata_backwards_compat_without_viewer_fields() { // Old JSON (no viewer_partstat, viewer_attendee_email, is_update) diff --git a/site/src/content/docs/guides/compose.md b/site/src/content/docs/guides/compose.md index 9ce299ce..6318ef87 100644 --- a/site/src/content/docs/guides/compose.md +++ b/site/src/content/docs/guides/compose.md @@ -56,6 +56,10 @@ Hello from mxr. Reply and forward drafts include message context. If the original message only had HTML, mxr uses the rendered reader output, not raw HTML tags. +## Reply recipient + +A reply targets the original message's `Reply-To:` header when the sender set one, falling back to `From:` otherwise. This is what mailing lists and `no-reply@` senders rely on — a reply to a list digest goes to the list, not the unmonitored sender address. `reply-all` adds the other original recipients as Cc on top of that target. + ## Send confirmation After the editor closes, mxr shows a confirmation modal: