Skip to content

Commit e6f388e

Browse files
committed
fix(gmail): use RFC-aware mailbox list parsing for recipient splitting
Replace naive comma-split with split_mailbox_list that respects quoted strings, so display names containing commas like "Doe, John" <john@example.com> are handled correctly in reply-all recipient parsing, deduplication, and --remove filtering.
1 parent 7c3929d commit e6f388e

1 file changed

Lines changed: 120 additions & 34 deletions

File tree

src/helpers/gmail/reply.rs

Lines changed: 120 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,36 @@ fn extract_reply_to_address(original: &OriginalMessage) -> String {
209209
}
210210
}
211211

212+
/// Split an RFC 5322 mailbox list on commas, respecting quoted strings.
213+
/// `"Doe, John" <john@example.com>, alice@example.com` →
214+
/// `["\"Doe, John\" <john@example.com>", "alice@example.com"]`
215+
fn split_mailbox_list(header: &str) -> Vec<&str> {
216+
let mut result = Vec::new();
217+
let mut in_quotes = false;
218+
let mut start = 0;
219+
220+
for (i, ch) in header.char_indices() {
221+
match ch {
222+
'"' => in_quotes = !in_quotes,
223+
',' if !in_quotes => {
224+
let token = header[start..i].trim();
225+
if !token.is_empty() {
226+
result.push(token);
227+
}
228+
start = i + 1;
229+
}
230+
_ => {}
231+
}
232+
}
233+
234+
let token = header[start..].trim();
235+
if !token.is_empty() {
236+
result.push(token);
237+
}
238+
239+
result
240+
}
241+
212242
/// Extract the bare email address from a header value like
213243
/// `"Alice <alice@example.com>"` → `"alice@example.com"` or
214244
/// `"alice@example.com"` → `"alice@example.com"`.
@@ -227,46 +257,32 @@ fn build_reply_all_recipients(
227257
remove: Option<&str>,
228258
) -> ReplyRecipients {
229259
let to = extract_reply_to_address(original);
230-
let to_emails: Vec<String> = to
231-
.split(',')
232-
.map(|s| extract_email(s.trim()).to_lowercase())
260+
let to_emails: Vec<String> = split_mailbox_list(&to)
261+
.iter()
262+
.map(|s| extract_email(s).to_lowercase())
233263
.filter(|s| !s.is_empty())
234264
.collect();
235265

236266
// Combine original To and Cc for the CC field (excluding the reply-to recipients)
237267
let mut cc_addrs: Vec<&str> = Vec::new();
238268

239269
if !original.to.is_empty() {
240-
for addr in original.to.split(',') {
241-
let addr = addr.trim();
242-
if !addr.is_empty() {
243-
cc_addrs.push(addr);
244-
}
245-
}
270+
cc_addrs.extend(split_mailbox_list(&original.to));
246271
}
247272
if !original.cc.is_empty() {
248-
for addr in original.cc.split(',') {
249-
let addr = addr.trim();
250-
if !addr.is_empty() {
251-
cc_addrs.push(addr);
252-
}
253-
}
273+
cc_addrs.extend(split_mailbox_list(&original.cc));
254274
}
255275

256276
// Add extra CC if provided
257277
if let Some(extra) = extra_cc {
258-
for addr in extra.split(',') {
259-
let addr = addr.trim();
260-
if !addr.is_empty() {
261-
cc_addrs.push(addr);
262-
}
263-
}
278+
cc_addrs.extend(split_mailbox_list(extra));
264279
}
265280

266281
// Remove addresses if requested (exact email match)
267282
let remove_set: Vec<String> = remove
268283
.map(|r| {
269-
r.split(',')
284+
split_mailbox_list(r)
285+
.iter()
270286
.map(|s| extract_email(s).to_lowercase())
271287
.collect()
272288
})
@@ -277,8 +293,7 @@ fn build_reply_all_recipients(
277293
.filter(|addr| {
278294
let email = extract_email(addr).to_lowercase();
279295
// Filter out the reply-to recipients (already in To) and removed addresses
280-
!to_emails.iter().any(|t| t == &email)
281-
&& !remove_set.iter().any(|r| r == &email)
296+
!to_emails.iter().any(|t| t == &email) && !remove_set.iter().any(|r| r == &email)
282297
})
283298
.collect();
284299

@@ -624,8 +639,7 @@ mod tests {
624639
date: "".to_string(),
625640
snippet: "".to_string(),
626641
};
627-
let recipients =
628-
build_reply_all_recipients(&original, None, Some("ann@example.com"));
642+
let recipients = build_reply_all_recipients(&original, None, Some("ann@example.com"));
629643
let cc = recipients.cc.unwrap();
630644
// joann@example.com should remain, ann@example.com should be removed
631645
assert_eq!(cc, "joann@example.com");
@@ -655,7 +669,10 @@ mod tests {
655669

656670
#[test]
657671
fn test_extract_email_malformed_no_closing_bracket() {
658-
assert_eq!(extract_email("Alice <alice@example.com"), "Alice <alice@example.com");
672+
assert_eq!(
673+
extract_email("Alice <alice@example.com"),
674+
"Alice <alice@example.com"
675+
);
659676
}
660677

661678
#[test]
@@ -702,11 +719,8 @@ mod tests {
702719
date: "".to_string(),
703720
snippet: "".to_string(),
704721
};
705-
let recipients = build_reply_all_recipients(
706-
&original,
707-
None,
708-
Some("Carol <carol@example.com>"),
709-
);
722+
let recipients =
723+
build_reply_all_recipients(&original, None, Some("Carol <carol@example.com>"));
710724
let cc = recipients.cc.unwrap();
711725
assert_eq!(cc, "bob@example.com");
712726
}
@@ -725,8 +739,7 @@ mod tests {
725739
date: "".to_string(),
726740
snippet: "".to_string(),
727741
};
728-
let recipients =
729-
build_reply_all_recipients(&original, Some("extra@example.com"), None);
742+
let recipients = build_reply_all_recipients(&original, Some("extra@example.com"), None);
730743
let cc = recipients.cc.unwrap();
731744
assert!(cc.contains("bob@example.com"));
732745
assert!(cc.contains("extra@example.com"));
@@ -793,4 +806,77 @@ mod tests {
793806
assert!(!cc.contains("list@example.com"));
794807
assert!(!cc.contains("owner@example.com"));
795808
}
809+
810+
#[test]
811+
fn test_split_mailbox_list_simple() {
812+
let addrs = split_mailbox_list("alice@example.com, bob@example.com");
813+
assert_eq!(addrs, vec!["alice@example.com", "bob@example.com"]);
814+
}
815+
816+
#[test]
817+
fn test_split_mailbox_list_quoted_comma() {
818+
let addrs =
819+
split_mailbox_list(r#""Doe, John" <john@example.com>, alice@example.com"#);
820+
assert_eq!(
821+
addrs,
822+
vec![r#""Doe, John" <john@example.com>"#, "alice@example.com"]
823+
);
824+
}
825+
826+
#[test]
827+
fn test_split_mailbox_list_single() {
828+
let addrs = split_mailbox_list("alice@example.com");
829+
assert_eq!(addrs, vec!["alice@example.com"]);
830+
}
831+
832+
#[test]
833+
fn test_split_mailbox_list_empty() {
834+
let addrs = split_mailbox_list("");
835+
assert!(addrs.is_empty());
836+
}
837+
838+
#[test]
839+
fn test_reply_all_with_quoted_comma_display_name() {
840+
let original = OriginalMessage {
841+
thread_id: "t1".to_string(),
842+
message_id_header: "".to_string(),
843+
references: "".to_string(),
844+
from: "sender@example.com".to_string(),
845+
reply_to: "".to_string(),
846+
to: r#""Doe, John" <john@example.com>, alice@example.com"#.to_string(),
847+
cc: "".to_string(),
848+
subject: "".to_string(),
849+
date: "".to_string(),
850+
snippet: "".to_string(),
851+
};
852+
let recipients = build_reply_all_recipients(&original, None, None);
853+
let cc = recipients.cc.unwrap();
854+
// Both addresses should be preserved intact
855+
assert!(cc.contains("john@example.com"));
856+
assert!(cc.contains("alice@example.com"));
857+
}
858+
859+
#[test]
860+
fn test_remove_with_quoted_comma_display_name() {
861+
let original = OriginalMessage {
862+
thread_id: "t1".to_string(),
863+
message_id_header: "".to_string(),
864+
references: "".to_string(),
865+
from: "sender@example.com".to_string(),
866+
reply_to: "".to_string(),
867+
to: r#""Doe, John" <john@example.com>, alice@example.com"#.to_string(),
868+
cc: "".to_string(),
869+
subject: "".to_string(),
870+
date: "".to_string(),
871+
snippet: "".to_string(),
872+
};
873+
let recipients = build_reply_all_recipients(
874+
&original,
875+
None,
876+
Some("john@example.com"),
877+
);
878+
let cc = recipients.cc.unwrap();
879+
assert!(!cc.contains("john@example.com"));
880+
assert!(cc.contains("alice@example.com"));
881+
}
796882
}

0 commit comments

Comments
 (0)