Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,11 @@ pub struct MessageMetadata {
pub text_html_source: Option<BodyPartSource>,
pub calendar: Option<CalendarMetadata>,
pub raw_headers: Option<String>,
/// 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<Address>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
Expand Down
20 changes: 14 additions & 6 deletions crates/daemon/src/handler/mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = std::collections::HashSet::new();
Expand Down
47 changes: 47 additions & 0 deletions crates/daemon/src/handler/tests/mutations_and_delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
36 changes: 36 additions & 0 deletions crates/mail-parse/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ fn extract_metadata(message: &Message<'_>, raw_headers: Option<String>) -> 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,
Expand All @@ -455,6 +457,7 @@ fn extract_metadata(message: &Message<'_>, raw_headers: Option<String>) -> Messa
text_html_source: None,
calendar: None,
raw_headers,
reply_to,
}
}

Expand Down Expand Up @@ -777,6 +780,39 @@ mod tests {
);
}

#[test]
fn reply_to_header_is_captured_into_metadata() {
let raw = concat!(
"From: No Reply <no-reply@list.example.com>\r\n",
"Reply-To: List Discussion <discuss@list.example.com>\r\n",
"To: me@example.com\r\n",
"Subject: Weekly digest\r\n",
"Message-ID: <digest-1@list.example.com>\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 <alice@example.com>\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)
Expand Down
4 changes: 4 additions & 0 deletions site/src/content/docs/guides/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading