, etc.) — no / wrapper needed.
+ With --html, inline images in the quoted message are preserved via cid: references.
+ Use --draft to save the reply as a draft instead of sending it immediately.",
+ ),
+ );
+
+ cmd = cmd.subcommand(
+ common_mail_args(
+ Command::new("+forward")
+ .about("[Helper] Forward a message to new recipients")
+ .arg(
+ Arg::new("message-id")
+ .long("message-id")
+ .help("Gmail message ID to forward")
+ .required(true)
+ .value_name("ID"),
+ )
+ .arg(
+ Arg::new("to")
+ .long("to")
+ .help("Recipient email address(es), comma-separated")
+ .required(true)
+ .value_name("EMAILS"),
+ )
+ .arg(
+ Arg::new("from")
+ .long("from")
+ .help("Sender address (for send-as/alias; omit to use account default)")
+ .value_name("EMAIL"),
+ )
+ .arg(
+ Arg::new("body")
+ .long("body")
+ .help("Optional note to include above the forwarded message (plain text, or HTML with --html)")
+ .value_name("TEXT"),
+ )
+ .arg(
+ Arg::new("no-original-attachments")
+ .long("no-original-attachments")
+ .help("Do not include file attachments from the original message (inline images in --html mode are preserved)")
+ .action(ArgAction::SetTrue),
+ ),
+ )
+ .after_help(
+ "\
+EXAMPLES:
+ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com
+ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below'
+ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com
+ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI
' --html
+ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf
+ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments
+ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --draft
+
+TIPS:
+ Includes the original message with sender, date, subject, and recipients.
+ Original attachments are included by default (matching Gmail web behavior).
+ With --html, inline images are also preserved via cid: references.
+ In plain-text mode, inline images are not included (matching Gmail web).
+ Use --no-original-attachments to forward without the original message's files.
+ Use -a/--attach to add extra file attachments. Can be specified multiple times.
+ Combined size of original and user attachments is limited to 25MB.
+ With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \
+Use fragment tags (, , , etc.) — no / wrapper needed.
+ Use --draft to save the forward as a draft instead of sending it immediately.",
+ ),
+ );
+
+ cmd = cmd.subcommand(
+ Command::new("+read")
+ .about("[Helper] Read a message and extract its body or headers")
+ .arg(
+ Arg::new("id")
+ .long("id")
+ .alias("message-id")
+ .required(true)
+ .help("The Gmail message ID to read")
+ .value_name("ID"),
+ )
+ .arg(
+ Arg::new("headers")
+ .long("headers")
+ .help("Include headers (From, To, Subject, Date) in the output")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new("format")
+ .long("format")
+ .help("Output format (text, json)")
+ .value_parser(["text", "json"])
+ .default_value("text"),
+ )
+ .arg(
+ Arg::new("html")
+ .long("html")
+ .help("Return HTML body instead of plain text")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new("dry-run")
+ .long("dry-run")
+ .help("Show the request that would be sent without executing it")
+ .action(ArgAction::SetTrue),
+ )
+ .after_help(
+ "\
+EXAMPLES:
+ gws gmail +read --id 18f1a2b3c4d
+ gws gmail +read --id 18f1a2b3c4d --headers
+ gws gmail +read --id 18f1a2b3c4d --format json | jq '.body'
+
+TIPS:
+ Converts HTML-only messages to plain text automatically.
+ Handles multipart/alternative and base64 decoding.",
+ ),
+ );
+
+ cmd = cmd.subcommand(
+ Command::new("+watch")
+ .about("[Helper] Watch for new emails and stream them as NDJSON")
+ .arg(
+ Arg::new("project")
+ .long("project")
+ .help("GCP project ID for Pub/Sub resources")
+ .value_name("PROJECT"),
+ )
+ .arg(
+ Arg::new("subscription")
+ .long("subscription")
+ .help("Existing Pub/Sub subscription name (skip setup)")
+ .value_name("NAME"),
+ )
+ .arg(
+ Arg::new("topic")
+ .long("topic")
+ .help("Existing Pub/Sub topic with Gmail push permission already granted")
+ .value_name("TOPIC"),
+ )
+ .arg(
+ Arg::new("label-ids")
+ .long("label-ids")
+ .help("Comma-separated Gmail label IDs to filter (e.g., INBOX,UNREAD)")
+ .value_name("LABELS"),
+ )
+ .arg(
+ Arg::new("max-messages")
+ .long("max-messages")
+ .help("Max messages per pull batch")
+ .value_name("N")
+ .default_value("10"),
+ )
+ .arg(
+ Arg::new("poll-interval")
+ .long("poll-interval")
+ .help("Seconds between pulls")
+ .value_name("SECS")
+ .default_value("5"),
+ )
+ .arg(
+ Arg::new("msg-format")
+ .long("msg-format")
+ .help("Gmail message format: full, metadata, minimal, raw")
+ .value_name("FORMAT")
+ .value_parser(["full", "metadata", "minimal", "raw"])
+ .default_value("full"),
+ )
+ .arg(
+ Arg::new("once")
+ .long("once")
+ .help("Pull once and exit")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new("cleanup")
+ .long("cleanup")
+ .help("Delete created Pub/Sub resources on exit")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new("output-dir")
+ .long("output-dir")
+ .help("Write each message to a separate JSON file in this directory")
+ .value_name("DIR"),
+ )
+ .after_help(
+ "\
+EXAMPLES:
+ gws gmail +watch --project my-gcp-project
+ gws gmail +watch --project my-project --label-ids INBOX --once
+ gws gmail +watch --subscription projects/p/subscriptions/my-sub
+ gws gmail +watch --project my-project --cleanup --output-dir ./emails
+
+TIPS:
+ Gmail watch expires after 7 days — re-run to renew.
+ Without --cleanup, Pub/Sub resources persist for reconnection.
+ Press Ctrl-C to stop gracefully.",
+ ),
+ );
+
+ cmd
+ }
+
+ fn handle<'a>(
+ &'a self,
+ doc: &'a crate::discovery::RestDescription,
+ matches: &'a ArgMatches,
+ sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
+ ) -> Pin> + Send + 'a>> {
+ Box::pin(async move {
+ if let Some(matches) = matches.subcommand_matches("+send") {
+ handle_send(doc, matches).await?;
+ return Ok(true);
+ }
+
+ if let Some(matches) = matches.subcommand_matches("+reply") {
+ handle_reply(doc, matches, false).await?;
+ return Ok(true);
+ }
+
+ if let Some(matches) = matches.subcommand_matches("+reply-all") {
+ handle_reply(doc, matches, true).await?;
+ return Ok(true);
+ }
+
+ if let Some(matches) = matches.subcommand_matches("+forward") {
+ handle_forward(doc, matches).await?;
+ return Ok(true);
+ }
+
+ if let Some(matches) = matches.subcommand_matches("+triage") {
+ handle_triage(matches).await?;
+ return Ok(true);
+ }
+
+ if let Some(matches) = matches.subcommand_matches("+read") {
+ handle_read(doc, matches).await?;
+ return Ok(true);
+ }
+
+ if let Some(matches) = matches.subcommand_matches("+watch") {
+ handle_watch(matches, sanitize_config).await?;
+ return Ok(true);
+ }
+
+ Ok(false)
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::HashMap;
+
+ /// Test-only wrapper: extract the plain text body from a payload using the single-pass walker.
+ fn extract_plain_text_body(payload: &Value) -> Option {
+ extract_payload_contents(payload).body_text
+ }
+
+ /// Test-only wrapper: extract the HTML body from a payload using the single-pass walker.
+ fn extract_html_body(payload: &Value) -> Option {
+ extract_payload_contents(payload).body_html
+ }
+
+ // --- Shared test helpers ---
+
+ /// Extract a header value from raw RFC 5322 output, handling folded lines.
+ /// Only searches the header block (before the first blank line).
+ pub(super) fn extract_header(raw: &str, name: &str) -> Option {
+ let prefix = format!("{}:", name);
+ let mut result: Option = None;
+ let mut collecting = false;
+ for line in raw.lines() {
+ // Blank line = end of headers per RFC 5322
+ if line.is_empty() || line == "\r" {
+ break;
+ }
+ if line.len()>= prefix.len() && line[..prefix.len()].eq_ignore_ascii_case(&prefix) {
+ result = Some(line[prefix.len()..].trim().to_string());
+ collecting = true;
+ } else if collecting && (line.starts_with(' ') || line.starts_with('\t')) {
+ if let Some(ref mut r) = result {
+ r.push(' ');
+ r.push_str(line.trim());
+ }
+ } else {
+ collecting = false;
+ }
+ }
+ result
+ }
+
+ /// Strip quoted-printable soft line breaks from raw output.
+ pub(super) fn strip_qp_soft_breaks(raw: &str) -> String {
+ raw.replace("=\r\n", "").replace("=\n", "")
+ }
+
+ // --- mail-builder integration tests ---
+
+ #[test]
+ fn test_to_mb_address_bare_email() {
+ let mailbox = Mailbox::parse("alice@example.com");
+ let mut mb = mail_builder::MessageBuilder::new();
+ mb = mb
+ .to(to_mb_address(&mailbox))
+ .subject("test")
+ .text_body("body");
+ let raw = mb.write_to_string().unwrap();
+ let to = extract_header(&raw, "To").unwrap();
+ assert!(to.contains("alice@example.com"));
+ }
+
+ #[test]
+ fn test_to_mb_address_with_display_name() {
+ let mailbox = Mailbox::parse("Alice Smith ");
+ let mut mb = mail_builder::MessageBuilder::new();
+ mb = mb
+ .to(to_mb_address(&mailbox))
+ .subject("test")
+ .text_body("body");
+ let raw = mb.write_to_string().unwrap();
+ let to = extract_header(&raw, "To").unwrap();
+ assert!(to.contains("alice@example.com"));
+ assert!(to.contains("Alice Smith"));
+ }
+
+ #[test]
+ fn test_to_mb_address_list_multiple() {
+ let mailboxes = Mailbox::parse_list("alice@example.com, Bob ");
+ let mut mb = mail_builder::MessageBuilder::new();
+ mb = mb
+ .to(to_mb_address_list(&mailboxes))
+ .subject("test")
+ .text_body("body");
+ let raw = mb.write_to_string().unwrap();
+ let to = extract_header(&raw, "To").unwrap();
+ assert!(to.contains("alice@example.com"));
+ assert!(to.contains("bob@example.com"));
+ assert!(to.contains("Bob"));
+ }
+
+ #[test]
+ fn test_set_threading_headers_output() {
+ let refs = vec![
+ "ref-1@example.com".to_string(),
+ "ref-2@example.com".to_string(),
+ ];
+ let threading = ThreadingHeaders {
+ in_reply_to: "reply-to@example.com",
+ references: &refs,
+ };
+ let mb = mail_builder::MessageBuilder::new();
+ let mb = mb
+ .to(MbAddress::new_address(None::<&str>, "test@example.com"))
+ .subject("test")
+ .text_body("body");
+ let mb = set_threading_headers(mb, &threading);
+ let raw = mb.write_to_string().unwrap();
+
+ let in_reply_to = extract_header(&raw, "In-Reply-To").unwrap();
+ assert!(in_reply_to.contains("reply-to@example.com"));
+
+ let references = extract_header(&raw, "References").unwrap();
+ assert!(references.contains("ref-1@example.com"));
+ assert!(references.contains("ref-2@example.com"));
+ }
+
+ // --- OriginalMessage tests ---
+
+ #[test]
+ fn test_original_message_default() {
+ let d = OriginalMessage::default();
+ assert!(d.thread_id.is_none());
+ assert!(d.message_id.is_empty());
+ assert!(d.references.is_empty());
+ assert!(d.from.email.is_empty());
+ assert!(d.from.name.is_none());
+ assert!(d.reply_to.is_none());
+ assert!(d.to.is_empty());
+ assert!(d.cc.is_none());
+ assert!(d.subject.is_empty());
+ assert!(d.date.is_none());
+ assert!(d.body_text.is_empty());
+ assert!(d.body_html.is_none());
+ assert!(d.parts.is_empty());
+ }
+
+ #[test]
+ fn test_parse_original_message_minimal() {
+ let msg = json!({
+ "threadId": "t1",
+ "snippet": "fallback text",
+ "payload": {
+ "mimeType": "text/plain",
+ "headers": [
+ { "name": "From", "value": "alice@example.com" },
+ { "name": "Subject", "value": "Hi" },
+ { "name": "Message-ID", "value": "" }
+ ],
+ "body": {
+ "data": URL_SAFE.encode("Hello")
+ }
+ }
+ });
+ let original = parse_original_message(&msg).unwrap();
+ assert_eq!(original.thread_id.as_deref(), Some("t1"));
+ assert_eq!(original.from.email, "alice@example.com");
+ assert_eq!(original.subject, "Hi");
+ assert_eq!(original.body_text, "Hello");
+ assert_eq!(original.message_id, "min@example.com");
+ // Missing optional fields default to None/empty
+ assert!(original.reply_to.is_none());
+ assert!(original.cc.is_none());
+ assert!(original.date.is_none());
+ assert!(original.references.is_empty());
+ assert!(original.body_html.is_none());
+ }
+
+ #[test]
+ fn test_parse_original_message_bare_message_id() {
+ let msg = json!({
+ "threadId": "t1",
+ "snippet": "",
+ "payload": {
+ "mimeType": "text/plain",
+ "headers": [
+ { "name": "From", "value": "alice@example.com" },
+ { "name": "Subject", "value": "Hi" },
+ { "name": "Message-ID", "value": "bare-id@example.com" }
+ ],
+ "body": { "data": URL_SAFE.encode("text") }
+ }
+ });
+ let original = parse_original_message(&msg).unwrap();
+ // Bare ID (no angle brackets) should be preserved as-is
+ assert_eq!(original.message_id, "bare-id@example.com");
+ }
+
+ #[test]
+ fn test_parse_original_message_missing_payload() {
+ let msg = json!({
+ "threadId": "t1",
+ "snippet": "fallback"
+ });
+ // Missing payload means no From or Message-ID → error
+ let result = parse_original_message(&msg);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn test_parse_original_message_missing_thread_id() {
+ let msg = json!({
+ "snippet": "text",
+ "payload": {
+ "mimeType": "text/plain",
+ "headers": [
+ { "name": "From", "value": "alice@example.com" },
+ { "name": "Message-ID", "value": "" }
+ ],
+ "body": { "data": URL_SAFE.encode("Hello") }
+ }
+ });
+ let result = parse_original_message(&msg).unwrap();
+ assert!(result.thread_id.is_none());
+ }
+
+ #[test]
+ fn test_parse_original_message_missing_from() {
+ let msg = json!({
+ "threadId": "t1",
+ "snippet": "text",
+ "payload": {
+ "mimeType": "text/plain",
+ "headers": [
+ { "name": "Message-ID", "value": "" }
+ ],
+ "body": { "data": URL_SAFE.encode("Hello") }
+ }
+ });
+ let result = parse_original_message(&msg);
+ assert!(result.is_err());
+ assert!(result.err().unwrap().to_string().contains("From"));
+ }
+
+ #[test]
+ fn test_parse_original_message_missing_message_id() {
+ let msg = json!({
+ "threadId": "t1",
+ "snippet": "text",
+ "payload": {
+ "mimeType": "text/plain",
+ "headers": [
+ { "name": "From", "value": "alice@example.com" }
+ ],
+ "body": { "data": URL_SAFE.encode("Hello") }
+ }
+ });
+ let result = parse_original_message(&msg);
+ assert!(result.is_err());
+ assert!(result.err().unwrap().to_string().contains("Message-ID"));
+ }
+
+ #[test]
+ fn test_parse_original_message_snippet_fallback() {
+ // When only text/html is present (no text/plain), body_text falls back to snippet
+ let msg = json!({
+ "threadId": "t1",
+ "snippet": "Snippet fallback text",
+ "payload": {
+ "mimeType": "text/html",
+ "headers": [
+ { "name": "From", "value": "alice@example.com" },
+ { "name": "Message-ID", "value": "" }
+ ],
+ "body": { "data": URL_SAFE.encode("HTML only
") }
+ }
+ });
+ let original = parse_original_message(&msg).unwrap();
+ assert_eq!(original.body_text, "Snippet fallback text");
+ assert_eq!(original.body_html.unwrap(), "HTML only
");
+ }
+
+ // --- extract_plain_text_body tests ---
+
+ #[test]
+ fn test_extract_plain_text_body_simple() {
+ let payload = json!({
+ "mimeType": "text/plain",
+ "body": {
+ "data": URL_SAFE.encode("Hello, world!")
+ }
+ });
+ assert_eq!(extract_plain_text_body(&payload).unwrap(), "Hello, world!");
+ }
+
+ #[test]
+ fn test_extract_plain_text_body_multipart() {
+ let payload = json!({
+ "mimeType": "multipart/alternative",
+ "parts": [
+ {
+ "mimeType": "text/plain",
+ "body": { "data": URL_SAFE.encode("Plain text body") }
+ },
+ {
+ "mimeType": "text/html",
+ "body": { "data": URL_SAFE.encode("HTML body
") }
+ }
+ ]
+ });
+ assert_eq!(
+ extract_plain_text_body(&payload).unwrap(),
+ "Plain text body"
+ );
+ }
+
+ #[test]
+ fn test_extract_plain_text_body_nested_multipart() {
+ let payload = json!({
+ "mimeType": "multipart/mixed",
+ "parts": [
+ {
+ "mimeType": "multipart/alternative",
+ "parts": [
+ {
+ "mimeType": "text/plain",
+ "body": { "data": URL_SAFE.encode("Nested plain text") }
+ },
+ {
+ "mimeType": "text/html",
+ "body": { "data": URL_SAFE.encode("HTML
") }
+ }
+ ]
+ },
+ {
+ "mimeType": "application/pdf",
+ "body": { "attachmentId": "att123" }
+ }
+ ]
+ });
+ assert_eq!(
+ extract_plain_text_body(&payload).unwrap(),
+ "Nested plain text"
+ );
+ }
+
+ #[test]
+ fn test_extract_plain_text_body_no_text_part() {
+ let payload = json!({
+ "mimeType": "text/html",
+ "body": { "data": URL_SAFE.encode("Only HTML
") }
+ });
+ assert!(extract_plain_text_body(&payload).is_none());
+ }
+
+ #[test]
+ fn test_inject_commands() {
+ let helper = GmailHelper;
+ let cmd = Command::new("test");
+ let doc = crate::discovery::RestDescription::default();
+
+ let cmd = helper.inject_commands(cmd, &doc);
+ let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
+ assert!(subcommands.contains(&"+watch"));
+ assert!(subcommands.contains(&"+send"));
+ assert!(subcommands.contains(&"+reply"));
+ assert!(subcommands.contains(&"+reply-all"));
+ assert!(subcommands.contains(&"+forward"));
+ assert!(subcommands.contains(&"+read"));
+ }
+
+ #[test]
+ fn test_build_send_metadata_with_thread_id() {
+ let metadata = build_send_metadata(Some("thread-123"), false).unwrap();
+ let parsed: Value = serde_json::from_str(&metadata).unwrap();
+ assert_eq!(parsed["threadId"], "thread-123");
+ }
+
+ #[test]
+ fn test_build_send_metadata_without_thread_id() {
+ assert!(build_send_metadata(None, false).is_none());
+ }
+
+ #[test]
+ fn test_build_send_metadata_draft_with_thread_id() {
+ let metadata = build_send_metadata(Some("thread-123"), true).unwrap();
+ let parsed: Value = serde_json::from_str(&metadata).unwrap();
+ assert_eq!(parsed["message"]["threadId"], "thread-123");
+ }
+
+ #[test]
+ fn test_build_send_metadata_draft_without_thread_id() {
+ let metadata = build_send_metadata(None, true).unwrap();
+ let parsed: Value = serde_json::from_str(&metadata).unwrap();
+ assert!(parsed["message"].is_object());
+ assert!(parsed["message"].get("threadId").is_none());
+ }
+
+ #[test]
+ fn test_append_address_list_header_value() {
+ let mut header_value = String::new();
+
+ append_address_list_header_value(&mut header_value, "alice@example.com");
+ append_address_list_header_value(&mut header_value, "bob@example.com");
+ append_address_list_header_value(&mut header_value, "");
+
+ assert_eq!(header_value, "alice@example.com, bob@example.com");
+ }
+
+ #[test]
+ fn test_parse_original_message_concatenates_repeated_address_and_reference_headers() {
+ let msg = json!({
+ "threadId": "thread-123",
+ "snippet": "Snippet fallback",
+ "payload": {
+ "mimeType": "text/html",
+ "headers": [
+ { "name": "From", "value": "alice@example.com" },
+ { "name": "Reply-To", "value": "team@example.com" },
+ { "name": "Reply-To", "value": "owner@example.com" },
+ { "name": "To", "value": "bob@example.com" },
+ { "name": "To", "value": "carol@example.com" },
+ { "name": "Cc", "value": "dave@example.com" },
+ { "name": "Cc", "value": "erin@example.com" },
+ { "name": "Subject", "value": "Hello" },
+ { "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" },
+ { "name": "Message-ID", "value": "" },
+ { "name": "References", "value": "" },
+ { "name": "References", "value": "" }
+ ],
+ "body": {
+ "data": URL_SAFE.encode("HTML only
")
+ }
+ }
+ });
+
+ let original = parse_original_message(&msg).unwrap();
+
+ assert_eq!(original.thread_id.as_deref(), Some("thread-123"));
+ assert_eq!(original.from.email, "alice@example.com");
+ let reply_to = original.reply_to.unwrap();
+ assert_eq!(reply_to.len(), 2);
+ assert_eq!(reply_to[0].email, "team@example.com");
+ assert_eq!(reply_to[1].email, "owner@example.com");
+ assert_eq!(original.to.len(), 2);
+ assert_eq!(original.to[0].email, "bob@example.com");
+ assert_eq!(original.to[1].email, "carol@example.com");
+ let cc = original.cc.unwrap();
+ assert_eq!(cc.len(), 2);
+ assert_eq!(cc[0].email, "dave@example.com");
+ assert_eq!(cc[1].email, "erin@example.com");
+ assert_eq!(original.subject, "Hello");
+ assert_eq!(
+ original.date.as_deref(),
+ Some("Fri, 6 Mar 2026 12:00:00 +0000")
+ );
+ assert_eq!(original.message_id, "msg@example.com");
+ assert_eq!(
+ original.references,
+ vec!["ref-1@example.com", "ref-2@example.com"]
+ );
+ assert_eq!(original.body_text, "Snippet fallback");
+ assert_eq!(original.body_html.as_deref(), Some("HTML only
"));
+ }
+
+ #[test]
+ fn test_parse_original_message_multipart_alternative() {
+ let msg = json!({
+ "threadId": "thread-456",
+ "snippet": "Snippet ignored when text/plain exists",
+ "payload": {
+ "mimeType": "multipart/alternative",
+ "headers": [
+ { "name": "From", "value": "alice@example.com" },
+ { "name": "To", "value": "bob@example.com" },
+ { "name": "Subject", "value": "Hello" },
+ { "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" },
+ { "name": "Message-ID", "value": "" }
+ ],
+ "parts": [
+ {
+ "mimeType": "text/plain",
+ "body": { "data": URL_SAFE.encode("Plain text body") }
+ },
+ {
+ "mimeType": "text/html",
+ "body": { "data": URL_SAFE.encode("Rich HTML body
") }
+ }
+ ]
+ }
+ });
+
+ let original = parse_original_message(&msg).unwrap();
+
+ assert_eq!(original.body_text, "Plain text body");
+ assert_eq!(original.body_html.as_deref(), Some("Rich HTML body
"));
+ }
+
+ #[test]
+ fn test_resolve_send_method_finds_gmail_send_method() {
+ let mut doc = crate::discovery::RestDescription::default();
+ let send_method = crate::discovery::RestMethod {
+ http_method: "POST".to_string(),
+ path: "gmail/v1/users/{userId}/messages/send".to_string(),
+ ..Default::default()
+ };
+
+ let mut messages = crate::discovery::RestResource::default();
+ messages.methods.insert("send".to_string(), send_method);
+
+ let mut users = crate::discovery::RestResource::default();
+ users.resources.insert("messages".to_string(), messages);
+
+ doc.resources = HashMap::from([("users".to_string(), users)]);
+
+ let resolved = resolve_send_method(&doc).unwrap();
+
+ assert_eq!(resolved.http_method, "POST");
+ assert_eq!(resolved.path, "gmail/v1/users/{userId}/messages/send");
+ }
+
+ #[test]
+ fn test_resolve_draft_method_finds_gmail_drafts_create_method() {
+ let mut doc = crate::discovery::RestDescription::default();
+ let create_method = crate::discovery::RestMethod {
+ http_method: "POST".to_string(),
+ path: "gmail/v1/users/{userId}/drafts".to_string(),
+ ..Default::default()
+ };
+
+ let mut drafts = crate::discovery::RestResource::default();
+ drafts.methods.insert("create".to_string(), create_method);
+
+ let mut users = crate::discovery::RestResource::default();
+ users.resources.insert("drafts".to_string(), drafts);
+
+ doc.resources = HashMap::from([("users".to_string(), users)]);
+
+ let resolved = resolve_draft_method(&doc).unwrap();
+
+ assert_eq!(resolved.http_method, "POST");
+ assert_eq!(resolved.path, "gmail/v1/users/{userId}/drafts");
+ }
+
+ #[test]
+ fn test_html_escape() {
+ assert_eq!(html_escape("Hello World"), "Hello World");
+ assert_eq!(
+ html_escape("Tom & Jerry "),
+ "Tom & Jerry <tj@example.com>"
+ );
+ assert_eq!(
+ html_escape("He said \"hello\""),
+ "He said "hello""
+ );
+ assert_eq!(html_escape("it's"), "it's");
+ assert_eq!(html_escape(""), "");
+ assert_eq!(
+ html_escape("a & b < c> d \"e\" f'g"),
+ "a & b < c > d "e" f'g"
+ );
+ }
+
+ #[test]
+ fn test_extract_html_body_direct() {
+ let payload = json!({
+ "mimeType": "text/html",
+ "body": {
+ "data": URL_SAFE.encode("Hello
")
+ }
+ });
+ assert_eq!(extract_html_body(&payload).as_deref(), Some("Hello
"));
+ }
+
+ #[test]
+ fn test_extract_html_body_from_multipart() {
+ let payload = json!({
+ "mimeType": "multipart/alternative",
+ "parts": [
+ {
+ "mimeType": "text/plain",
+ "body": { "data": URL_SAFE.encode("plain text") }
+ },
+ {
+ "mimeType": "text/html",
+ "body": { "data": URL_SAFE.encode("rich text
") }
+ }
+ ]
+ });
+ assert_eq!(
+ extract_html_body(&payload).as_deref(),
+ Some("rich text
")
+ );
+ }
+
+ #[test]
+ fn test_extract_html_body_missing() {
+ let payload = json!({
+ "mimeType": "text/plain",
+ "body": { "data": URL_SAFE.encode("only plain") }
+ });
+ assert!(extract_html_body(&payload).is_none());
+ }
+
+ #[test]
+ fn test_extract_html_body_from_nested_multipart() {
+ let payload = json!({
+ "mimeType": "multipart/mixed",
+ "parts": [
+ {
+ "mimeType": "multipart/alternative",
+ "parts": [
+ {
+ "mimeType": "text/plain",
+ "body": { "data": URL_SAFE.encode("plain text") }
+ },
+ {
+ "mimeType": "text/html",
+ "body": { "data": URL_SAFE.encode("Nested HTML
") }
+ }
+ ]
+ },
+ {
+ "mimeType": "application/pdf",
+ "body": { "attachmentId": "att123" }
+ }
+ ]
+ });
+ assert_eq!(
+ extract_html_body(&payload).as_deref(),
+ Some("Nested HTML
")
+ );
+ }
+
+ #[test]
+ fn test_resolve_html_body_uses_html_when_present() {
+ let original = OriginalMessage {
+ body_text: "ignored".to_string(),
+ body_html: Some("Real HTML
".to_string()),
+ ..OriginalMessage::dry_run_placeholder("test")
+ };
+ assert_eq!(resolve_html_body(&original), "Real HTML
");
+ }
+
+ #[test]
+ fn test_resolve_html_body_escapes_plain_text_fallback() {
+ let original = OriginalMessage {
+ body_text: "Line 1 & \nLine 2\r\nLine 3".to_string(),
+ body_html: None,
+ ..OriginalMessage::dry_run_placeholder("test")
+ };
+ let result = resolve_html_body(&original);
+ assert_eq!(
+ result,
+ "Line 1 & <tag>
\r\nLine 2
\r\nLine 3"
+ );
+ }
+
+ // --- Mailbox type tests ---
+
+ #[test]
+ fn test_mailbox_parse_bare_email() {
+ let m = Mailbox::parse("alice@example.com");
+ assert_eq!(m.email, "alice@example.com");
+ assert!(m.name.is_none());
+ }
+
+ #[test]
+ fn test_mailbox_parse_with_display_name() {
+ let m = Mailbox::parse("Alice Smith ");
+ assert_eq!(m.email, "alice@example.com");
+ assert_eq!(m.name.as_deref(), Some("Alice Smith"));
+ }
+
+ #[test]
+ fn test_mailbox_parse_quoted_display_name() {
+ let m = Mailbox::parse("\"Bob, Jr.\" ");
+ assert_eq!(m.email, "bob@example.com");
+ assert_eq!(m.name.as_deref(), Some("Bob, Jr."));
+ }
+
+ #[test]
+ fn test_mailbox_parse_malformed_no_closing_bracket() {
+ let m = Mailbox::parse("Alice ");
+ // Empty email inside angle brackets
+ assert_eq!(m.email, "");
+ assert_eq!(m.name.as_deref(), Some("Alice"));
+ }
+
+ #[test]
+ fn test_mailbox_parse_strips_crlf_injection_in_email() {
+ let m = Mailbox::parse("foo@bar.com\r\nBcc: evil@attacker.com");
+ assert_eq!(m.email, "foo@bar.comBcc: evil@attacker.com");
+ assert!(!m.email.contains('\r'));
+ assert!(!m.email.contains('\n'));
+ }
+
+ #[test]
+ fn test_mailbox_parse_strips_crlf_injection_in_angle_bracket_email() {
+ let m = Mailbox::parse("Alice ");
+ assert!(!m.email.contains('\r'));
+ assert!(!m.email.contains('\n'));
+ assert!(m.email.contains("foo@bar.com"));
+ }
+
+ #[test]
+ fn test_mailbox_parse_strips_control_chars_from_name() {
+ let m = Mailbox::parse("Alice0円Bob ");
+ assert_eq!(m.name.as_deref(), Some("AliceBob"));
+ assert!(!m.name.unwrap().contains('0円'));
+ }
+
+ #[test]
+ fn test_mailbox_parse_strips_null_bytes_from_email() {
+ let m = Mailbox::parse("alice0円@example.com");
+ assert_eq!(m.email, "alice@example.com");
+ }
+
+ #[test]
+ fn test_mailbox_parse_strips_tab_from_email() {
+ let m = Mailbox::parse("alice\t@example.com");
+ assert_eq!(m.email, "alice@example.com");
+ }
+
+ #[test]
+ fn test_mailbox_parse_non_ascii_display_name() {
+ let m = Mailbox::parse("田中太郎 ");
+ assert_eq!(m.email, "tanaka@example.com");
+ assert_eq!(m.name.as_deref(), Some("田中太郎"));
+
+ // Verify non-ASCII name flows through to mail-builder without panic
+ // and gets RFC 2047 encoded (replacing hand-rolled encode_address_header from #482)
+ let mb = mail_builder::MessageBuilder::new()
+ .to(to_mb_address(&m))
+ .subject("test")
+ .text_body("body");
+ let raw = mb.write_to_string().unwrap();
+ assert!(raw.contains("tanaka@example.com"));
+ assert!(!raw.contains("田中太郎")); // raw CJK should be RFC 2047 encoded
+ assert!(raw.contains("=?utf-8?")); // encoded-word present
+ }
+
+ #[test]
+ fn test_mailbox_parse_list() {
+ let list = Mailbox::parse_list("alice@example.com, Bob ");
+ assert_eq!(list.len(), 2);
+ assert_eq!(list[0].email, "alice@example.com");
+ assert_eq!(list[1].email, "bob@example.com");
+ assert_eq!(list[1].name.as_deref(), Some("Bob"));
+ }
+
+ #[test]
+ fn test_mailbox_parse_list_with_quoted_comma() {
+ let list = Mailbox::parse_list(r#""Doe, John" , alice@example.com"#);
+ assert_eq!(list.len(), 2);
+ assert_eq!(list[0].email, "john@example.com");
+ assert_eq!(list[0].name.as_deref(), Some("Doe, John"));
+ assert_eq!(list[1].email, "alice@example.com");
+ }
+
+ #[test]
+ fn test_mailbox_parse_list_filters_empty_emails() {
+ // Empty string → empty vec
+ assert!(Mailbox::parse_list("").is_empty());
+
+ // Whitespace-only commas → empty vec
+ assert!(Mailbox::parse_list(" , , ").is_empty());
+
+ // Trailing comma → no phantom entry
+ let list = Mailbox::parse_list("alice@example.com,");
+ assert_eq!(list.len(), 1);
+ assert_eq!(list[0].email, "alice@example.com");
+
+ // Leading comma
+ let list = Mailbox::parse_list(",alice@example.com");
+ assert_eq!(list.len(), 1);
+ assert_eq!(list[0].email, "alice@example.com");
+
+ // Empty angle brackets filtered
+ let list = Mailbox::parse_list("Alice , bob@example.com");
+ assert_eq!(list.len(), 1);
+ assert_eq!(list[0].email, "bob@example.com");
+ }
+
+ #[test]
+ fn test_mailbox_display() {
+ let bare = Mailbox {
+ name: None,
+ email: "alice@example.com".to_string(),
+ };
+ assert_eq!(bare.to_string(), "alice@example.com");
+
+ let named = Mailbox {
+ name: Some("Alice".to_string()),
+ email: "alice@example.com".to_string(),
+ };
+ assert_eq!(named.to_string(), "Alice ");
+ }
+
+ /// Regression test for PR #513: display names with RFC 2822 special characters
+ /// (commas, parens, colons, etc.) must be properly quoted in the To: header
+ /// so Gmail does not reject them with "Invalid To header".
+ #[test]
+ fn test_rfc2822_display_name_quoting_via_mail_builder() {
+ let test_cases = [
+ ("Anderson, Rich (CORP)", "rich@example.com", "comma/parens"),
+ ("Dr. Smith: Chief", "smith@example.com", "colon"),
+ ("O'Brien & Co.", "ob@example.com", "dot/ampersand"),
+ ];
+
+ for (name, email, description) in test_cases {
+ let m = Mailbox {
+ name: Some(name.to_string()),
+ email: email.to_string(),
+ };
+ let raw = mail_builder::MessageBuilder::new()
+ .to(to_mb_address(&m))
+ .subject("test")
+ .text_body("body")
+ .write_to_string()
+ .unwrap();
+ let to_line = raw
+ .lines()
+ .find(|l| l.starts_with("To:"))
+ .unwrap_or_else(|| panic!("No To: header for case: {description}"));
+
+ let quoted = format!("\"{name}\"");
+ assert!(
+ to_line.contains("ed) || to_line.contains("=?utf-8?"),
+ "Display name with {description} must be quoted: {to_line}"
+ );
+ }
+ }
+
+ #[test]
+ fn test_strip_angle_brackets() {
+ assert_eq!(strip_angle_brackets(""), "abc@example.com");
+ assert_eq!(strip_angle_brackets("abc@example.com"), "abc@example.com");
+ assert_eq!(
+ strip_angle_brackets(" "),
+ "abc@example.com"
+ );
+ }
+
+ #[test]
+ fn test_build_references_chain() {
+ // Empty references + message ID
+ let original = OriginalMessage {
+ message_id: "msg-1@example.com".to_string(),
+ ..Default::default()
+ };
+ assert_eq!(build_references_chain(&original), vec!["msg-1@example.com"]);
+
+ // Existing references + message ID
+ let original = OriginalMessage {
+ message_id: "msg-2@example.com".to_string(),
+ references: vec![
+ "msg-0@example.com".to_string(),
+ "msg-1@example.com".to_string(),
+ ],
+ ..Default::default()
+ };
+ assert_eq!(
+ build_references_chain(&original),
+ vec![
+ "msg-0@example.com",
+ "msg-1@example.com",
+ "msg-2@example.com"
+ ]
+ );
+
+ // Empty message ID doesn't add to chain
+ let original = OriginalMessage {
+ message_id: String::new(),
+ references: vec!["msg-0@example.com".to_string()],
+ ..Default::default()
+ };
+ assert_eq!(build_references_chain(&original), vec!["msg-0@example.com"]);
+ }
+
+ // --- HTML fidelity helper tests ---
+
+ #[test]
+ fn test_format_sender_for_attribution() {
+ // Bare email
+ let bare = Mailbox::parse("alice@example.com");
+ assert_eq!(
+ format_sender_for_attribution(&bare),
+ "alice@example.com"
+ );
+ // Name
+ let named = Mailbox::parse("Alice Smith ");
+ assert_eq!(
+ format_sender_for_attribution(&named),
+ "Alice Smith <alice@example.com>"
+ );
+ // Special chars in name
+ let special = Mailbox::parse("O'Brien & Co ");
+ assert_eq!(
+ format_sender_for_attribution(&special),
+ "O'Brien & Co <ob@example.com>"
+ );
+ }
+
+ #[test]
+ fn test_format_email_link_prevents_mailto_injection() {
+ // A crafted email with ?cc= must be percent-encoded in the href so the
+ // browser does not interpret it as a mailto parameter.
+ let link = format_email_link("user@example.com?cc=evil@attacker.com");
+ assert!(link.contains("mailto:"));
+ // The href must not contain raw ?cc= (it should be percent-encoded)
+ assert!(!link.contains("mailto:user@example.com?cc="));
+ assert!(link.contains("%3F")); // ? encoded
+ assert!(link.contains("%3D")); // = encoded
+ }
+
+ #[test]
+ fn test_format_address_list_with_links() {
+ let single = vec![Mailbox::parse("alice@example.com")];
+ assert_eq!(
+ format_address_list_with_links(&single),
+ "alice@example.com"
+ );
+ let multi = vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ];
+ assert_eq!(
+ format_address_list_with_links(&multi),
+ "alice@example.com, \
+ bob@example.com"
+ );
+ let with_name = Mailbox::parse_list(r#""Doe, John" , alice@example.com"#);
+ assert_eq!(
+ format_address_list_with_links(&with_name),
+ "Doe, John <john@example.com>, \
+ alice@example.com"
+ );
+ assert_eq!(format_address_list_with_links(&[]), "");
+ }
+
+ #[test]
+ fn test_format_date_for_attribution() {
+ assert_eq!(
+ format_date_for_attribution("Wed, 04 Mar 2026 15:01:00 +0000"),
+ "Wed, Mar 4, 2026 at 3:01\u{202f}PM"
+ );
+ assert_eq!(
+ format_date_for_attribution("Jan 1 <2026>"),
+ "Jan 1 <2026>"
+ );
+ }
+
+ #[test]
+ fn test_format_forward_from() {
+ let named = Mailbox::parse("Alice Smith ");
+ assert_eq!(
+ format_forward_from(&named),
+ "Alice Smith \
+ <alice@example.com>"
+ );
+ let bare = Mailbox::parse("alice@example.com");
+ assert_eq!(
+ format_forward_from(&bare),
+ "alice@example.com \
+ <alice@example.com>"
+ );
+ }
+
+ #[test]
+ fn test_split_raw_mailbox_list() {
+ assert_eq!(
+ split_raw_mailbox_list("alice@example.com, bob@example.com"),
+ vec!["alice@example.com", "bob@example.com"]
+ );
+ assert_eq!(
+ split_raw_mailbox_list("alice@example.com"),
+ vec!["alice@example.com"]
+ );
+ assert!(split_raw_mailbox_list("").is_empty());
+ assert_eq!(
+ split_raw_mailbox_list(r#""Doe, John" , alice@example.com"#),
+ vec![r#""Doe, John" "#, "alice@example.com"]
+ );
+ assert_eq!(
+ split_raw_mailbox_list(r#""Doe \"JD, Sr\"" , alice@example.com"#),
+ vec![
+ r#""Doe \"JD, Sr\"" "#,
+ "alice@example.com"
+ ]
+ );
+ assert_eq!(
+ split_raw_mailbox_list(r#""Trail\\" , b@example.com"#),
+ vec![r#""Trail\\" "#, "b@example.com"]
+ );
+ }
+
+ #[test]
+ fn test_parse_optional_trimmed() {
+ let cmd = Command::new("test")
+ .arg(Arg::new("flag").long("flag"))
+ .arg(Arg::new("empty").long("empty"))
+ .arg(Arg::new("ws").long("ws"));
+
+ // Present, non-empty value
+ let matches = cmd
+ .clone()
+ .try_get_matches_from(["test", "--flag", "value"])
+ .unwrap();
+ assert_eq!(
+ parse_optional_trimmed(&matches, "flag"),
+ Some("value".to_string())
+ );
+
+ // Absent argument
+ let matches = cmd.clone().try_get_matches_from(["test"]).unwrap();
+ assert!(parse_optional_trimmed(&matches, "flag").is_none());
+
+ // Whitespace-only becomes None
+ let matches = cmd
+ .clone()
+ .try_get_matches_from(["test", "--ws", " "])
+ .unwrap();
+ assert!(parse_optional_trimmed(&matches, "ws").is_none());
+
+ // Empty string becomes None
+ let matches = cmd.try_get_matches_from(["test", "--empty", ""]).unwrap();
+ assert!(parse_optional_trimmed(&matches, "empty").is_none());
+ }
+
+ // --- Attachment tests ---
+
+ fn make_attach_matches(args: &[&str]) -> ArgMatches {
+ let cmd = Command::new("test").arg(
+ Arg::new("attach")
+ .short('a')
+ .long("attach")
+ .action(ArgAction::Append),
+ );
+ cmd.try_get_matches_from(args).unwrap()
+ }
+
+ #[test]
+ fn test_attachment_single_file() {
+ let att = Attachment {
+ filename: "report.pdf".to_string(),
+ content_type: "application/pdf".to_string(),
+ data: b"fake pdf data".to_vec(),
+ content_id: None,
+ };
+ let mb = mail_builder::MessageBuilder::new()
+ .to(MbAddress::new_address(None::<&str>, "test@example.com"))
+ .subject("test");
+ let raw = finalize_message(mb, "Body", false, &[att]).unwrap();
+
+ assert!(raw.contains("multipart/mixed"));
+ assert!(raw.contains("report.pdf"));
+ assert!(raw.contains("application/pdf"));
+ assert!(raw.contains("Body"));
+ }
+
+ #[test]
+ fn test_attachment_multiple_files() {
+ let attachments = vec![
+ Attachment {
+ filename: "a.pdf".to_string(),
+ content_type: "application/pdf".to_string(),
+ data: b"pdf data".to_vec(),
+ content_id: None,
+ },
+ Attachment {
+ filename: "b.csv".to_string(),
+ content_type: "text/csv".to_string(),
+ data: b"csv data".to_vec(),
+ content_id: None,
+ },
+ ];
+ let mb = mail_builder::MessageBuilder::new()
+ .to(MbAddress::new_address(None::<&str>, "test@example.com"))
+ .subject("test");
+ let raw = finalize_message(mb, "Body", false, &attachments).unwrap();
+
+ assert!(raw.contains("multipart/mixed"));
+ assert!(raw.contains("a.pdf"));
+ assert!(raw.contains("b.csv"));
+ }
+
+ #[test]
+ fn test_attachment_with_html_body() {
+ let att = Attachment {
+ filename: "image.png".to_string(),
+ content_type: "image/png".to_string(),
+ data: vec![0x89, 0x50, 0x4E, 0x47],
+ content_id: None,
+ };
+ let mb = mail_builder::MessageBuilder::new()
+ .to(MbAddress::new_address(None::<&str>, "test@example.com"))
+ .subject("test");
+ let raw = finalize_message(mb, "Hello
", true, &[att]).unwrap();
+ let decoded = strip_qp_soft_breaks(&raw);
+
+ assert!(raw.contains("multipart/mixed"));
+ assert!(decoded.contains("text/html"));
+ assert!(decoded.contains("Hello
"));
+ assert!(raw.contains("image.png"));
+ }
+
+ #[test]
+ fn test_attachment_empty_produces_no_multipart() {
+ let mb = mail_builder::MessageBuilder::new()
+ .to(MbAddress::new_address(None::<&str>, "test@example.com"))
+ .subject("test");
+ let raw = finalize_message(mb, "Body", false, &[]).unwrap();
+
+ assert!(!raw.contains("multipart/mixed"));
+ assert!(raw.contains("text/plain"));
+ }
+
+ #[test]
+ fn test_parse_attachments_rejects_control_chars() {
+ let matches = make_attach_matches(&["test", "-a", "file0円name.pdf"]);
+ let err = parse_attachments(&matches).unwrap_err();
+ assert!(err.to_string().contains("control characters"));
+ }
+
+ #[test]
+ fn test_parse_attachments_rejects_directory() {
+ // Use a relative directory that exists in CWD
+ let matches = make_attach_matches(&["test", "-a", "src"]);
+ let err = parse_attachments(&matches).unwrap_err();
+ assert!(err.to_string().contains("not a regular file"));
+ }
+
+ #[test]
+ fn test_parse_attachments_empty_returns_empty_vec() {
+ let matches = make_attach_matches(&["test"]);
+ let attachments = parse_attachments(&matches).unwrap();
+ assert!(attachments.is_empty());
+ }
+
+ #[test]
+ fn test_parse_attachments_reads_real_file() {
+ use std::io::Write;
+ let cwd = std::env::current_dir().unwrap().canonicalize().unwrap();
+ let dir = tempfile::tempdir_in(&cwd).unwrap();
+ let file_path = dir.path().join("test.txt");
+ let mut f = std::fs::File::create(&file_path).unwrap();
+ f.write_all(b"hello world").unwrap();
+ drop(f);
+
+ let path_str = file_path.to_str().unwrap().to_string();
+ let matches = make_attach_matches(&["test", "-a", &path_str]);
+ let attachments = parse_attachments(&matches).unwrap();
+
+ assert_eq!(attachments.len(), 1);
+ assert_eq!(attachments[0].filename, "test.txt");
+ assert_eq!(attachments[0].content_type, "text/plain");
+ assert_eq!(attachments[0].data, b"hello world");
+ }
+
+ #[test]
+ fn test_parse_attachments_nonexistent_file() {
+ let matches = make_attach_matches(&["test", "-a", "nonexistent_file.pdf"]);
+ let err = parse_attachments(&matches).unwrap_err();
+ assert!(
+ err.to_string().contains("nonexistent_file.pdf"),
+ "error should include the path: {}",
+ err
+ );
+ }
+
+ #[test]
+ fn test_parse_attachments_unknown_extension_falls_back_to_octet_stream() {
+ use std::io::Write;
+ let cwd = std::env::current_dir().unwrap().canonicalize().unwrap();
+ let dir = tempfile::tempdir_in(&cwd).unwrap();
+ let file_path = dir.path().join("data.zzqqxx");
+ let mut f = std::fs::File::create(&file_path).unwrap();
+ f.write_all(b"unknown format").unwrap();
+ drop(f);
+
+ let path_str = file_path.to_str().unwrap().to_string();
+ let matches = make_attach_matches(&["test", "-a", &path_str]);
+ let attachments = parse_attachments(&matches).unwrap();
+
+ assert_eq!(attachments[0].content_type, "application/octet-stream");
+ }
+
+ #[test]
+ fn test_parse_attachments_size_limit_accumulates() {
+ let cwd = std::env::current_dir().unwrap().canonicalize().unwrap();
+ let dir = tempfile::tempdir_in(&cwd).unwrap();
+
+ // Create two files whose combined size exceeds MAX_TOTAL_ATTACHMENT_BYTES
+ let file1 = dir.path().join("big1.bin");
+ let file2 = dir.path().join("big2.bin");
+ // Each file is just over half the limit
+ let half_plus_one = (MAX_TOTAL_ATTACHMENT_BYTES / 2 + 1) as usize;
+ std::fs::write(&file1, vec![0u8; half_plus_one]).unwrap();
+ std::fs::write(&file2, vec![0u8; half_plus_one]).unwrap();
+
+ let path1 = file1.to_str().unwrap().to_string();
+ let path2 = file2.to_str().unwrap().to_string();
+ let matches = make_attach_matches(&["test", "-a", &path1, "-a", &path2]);
+ let err = parse_attachments(&matches).unwrap_err();
+ assert!(
+ err.to_string().contains("exceeds"),
+ "error should mention exceeding limit: {}",
+ err
+ );
+
+ // A single file under the limit should succeed
+ let matches = make_attach_matches(&["test", "-a", &path1]);
+ assert!(parse_attachments(&matches).is_ok());
+ }
+
+ #[test]
+ fn test_parse_attachments_rejects_empty_file() {
+ let cwd = std::env::current_dir().unwrap().canonicalize().unwrap();
+ let dir = tempfile::tempdir_in(&cwd).unwrap();
+ let file_path = dir.path().join("empty.txt");
+ std::fs::write(&file_path, b"").unwrap();
+
+ let path_str = file_path.to_str().unwrap().to_string();
+ let matches = make_attach_matches(&["test", "-a", &path_str]);
+ let err = parse_attachments(&matches).unwrap_err();
+ assert!(
+ err.to_string().contains("empty (0 bytes)"),
+ "error should mention empty file: {}",
+ err
+ );
+ }
+
+ // --- resolve_sender_from_identities tests ---
+
+ #[test]
+ fn test_parse_send_as_response() {
+ let body = serde_json::json!({
+ "sendAs": [
+ {
+ "sendAsEmail": "malo@intelligence.org",
+ "displayName": "Malo Bourgon",
+ "replyToAddress": "",
+ "signature": "",
+ "isPrimary": true,
+ "isDefault": true,
+ "treatAsAlias": false,
+ "verificationStatus": "accepted"
+ },
+ {
+ "sendAsEmail": "malo@work.com",
+ "displayName": "Malo (Work)",
+ "replyToAddress": "",
+ "signature": "",
+ "isPrimary": false,
+ "isDefault": false,
+ "treatAsAlias": true,
+ "verificationStatus": "accepted"
+ },
+ {
+ "sendAsEmail": "noreply@example.com",
+ "displayName": "",
+ "isPrimary": false,
+ "isDefault": false,
+ "verificationStatus": "accepted"
+ }
+ ]
+ });
+
+ let ids = parse_send_as_response(&body);
+ assert_eq!(ids.len(), 3);
+
+ assert_eq!(ids[0].mailbox.email, "malo@intelligence.org");
+ assert_eq!(ids[0].mailbox.name.as_deref(), Some("Malo Bourgon"));
+ assert!(ids[0].is_default);
+
+ assert_eq!(ids[1].mailbox.email, "malo@work.com");
+ assert_eq!(ids[1].mailbox.name.as_deref(), Some("Malo (Work)"));
+ assert!(!ids[1].is_default);
+
+ // Empty displayName becomes None
+ assert_eq!(ids[2].mailbox.email, "noreply@example.com");
+ assert!(ids[2].mailbox.name.is_none());
+ assert!(!ids[2].is_default);
+ }
+
+ #[test]
+ fn test_parse_send_as_response_empty() {
+ let body = serde_json::json!({});
+ let ids = parse_send_as_response(&body);
+ assert!(ids.is_empty());
+ }
+
+ #[test]
+ fn test_parse_send_as_response_skips_missing_email() {
+ let body = serde_json::json!({
+ "sendAs": [
+ { "displayName": "No Email", "isDefault": true },
+ { "sendAsEmail": "valid@example.com", "isDefault": false }
+ ]
+ });
+ let ids = parse_send_as_response(&body);
+ assert_eq!(ids.len(), 1);
+ assert_eq!(ids[0].mailbox.email, "valid@example.com");
+ }
+
+ fn make_identities() -> Vec {
+ vec![
+ SendAsIdentity {
+ mailbox: Mailbox {
+ name: Some("Malo Bourgon".to_string()),
+ email: "malo@intelligence.org".to_string(),
+ },
+ is_default: true,
+ },
+ SendAsIdentity {
+ mailbox: Mailbox {
+ name: Some("Malo (Work)".to_string()),
+ email: "malo@work.com".to_string(),
+ },
+ is_default: false,
+ },
+ ]
+ }
+
+ #[test]
+ fn test_resolve_sender_no_from_returns_default() {
+ let ids = make_identities();
+ let result = resolve_sender_from_identities(None, &ids);
+ let addrs = result.unwrap();
+ assert_eq!(addrs.len(), 1);
+ assert_eq!(addrs[0].email, "malo@intelligence.org");
+ assert_eq!(addrs[0].name.as_deref(), Some("Malo Bourgon"));
+ }
+
+ #[test]
+ fn test_resolve_sender_bare_email_enriched() {
+ let ids = make_identities();
+ let from = [Mailbox::parse("malo@work.com")];
+ let result = resolve_sender_from_identities(Some(&from), &ids);
+ let addrs = result.unwrap();
+ assert_eq!(addrs[0].email, "malo@work.com");
+ assert_eq!(addrs[0].name.as_deref(), Some("Malo (Work)"));
+ }
+
+ #[test]
+ fn test_resolve_sender_bare_email_case_insensitive() {
+ let ids = make_identities();
+ let from = [Mailbox::parse("Malo@Work.Com")];
+ let result = resolve_sender_from_identities(Some(&from), &ids);
+ let addrs = result.unwrap();
+ assert_eq!(addrs[0].name.as_deref(), Some("Malo (Work)"));
+ }
+
+ #[test]
+ fn test_resolve_sender_bare_email_not_in_list_passes_through() {
+ let ids = make_identities();
+ let from = [Mailbox::parse("unknown@example.com")];
+ let result = resolve_sender_from_identities(Some(&from), &ids);
+ let addrs = result.unwrap();
+ assert_eq!(addrs[0].email, "unknown@example.com");
+ assert!(addrs[0].name.is_none());
+ }
+
+ #[test]
+ fn test_resolve_sender_with_display_name_returns_as_is() {
+ let ids = make_identities();
+ let from = [Mailbox::parse("Custom Name ")];
+ let result = resolve_sender_from_identities(Some(&from), &ids);
+ let addrs = result.unwrap();
+ assert_eq!(addrs[0].email, "malo@work.com");
+ assert_eq!(addrs[0].name.as_deref(), Some("Custom Name"));
+ }
+
+ #[test]
+ fn test_resolve_sender_mixed_enriches_only_bare() {
+ let ids = make_identities();
+ let from = [
+ Mailbox::parse("Custom "),
+ Mailbox::parse("malo@work.com"),
+ ];
+ let result = resolve_sender_from_identities(Some(&from), &ids);
+ let addrs = result.unwrap();
+ // First has explicit name — kept as-is
+ assert_eq!(addrs[0].name.as_deref(), Some("Custom"));
+ // Second was bare — enriched from send-as list
+ assert_eq!(addrs[1].name.as_deref(), Some("Malo (Work)"));
+ }
+
+ #[test]
+ fn test_resolve_sender_no_default_in_list() {
+ let ids = vec![SendAsIdentity {
+ mailbox: Mailbox {
+ name: Some("Alias".to_string()),
+ email: "alias@example.com".to_string(),
+ },
+ is_default: false,
+ }];
+ let result = resolve_sender_from_identities(None, &ids);
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn test_resolve_sender_empty_display_name_treated_as_none() {
+ let ids = vec![SendAsIdentity {
+ mailbox: Mailbox {
+ name: None,
+ email: "bare@example.com".to_string(),
+ },
+ is_default: true,
+ }];
+ let result = resolve_sender_from_identities(None, &ids);
+ let addrs = result.unwrap();
+ assert_eq!(addrs[0].email, "bare@example.com");
+ assert!(addrs[0].name.is_none());
+ }
+
+ // --- parse_profile_display_name tests ---
+
+ #[test]
+ fn test_parse_profile_display_name() {
+ let body = serde_json::json!({
+ "resourceName": "people/112118466613566642951",
+ "etag": "%EgUBAi43PRoEAQIFByIMR0xCc0FMcVBJQmc9",
+ "names": [{
+ "metadata": {
+ "primary": true,
+ "source": { "type": "DOMAIN_PROFILE", "id": "112118466613566642951" }
+ },
+ "displayName": "Malo Bourgon",
+ "familyName": "Bourgon",
+ "givenName": "Malo",
+ "displayNameLastFirst": "Bourgon, Malo"
+ }]
+ });
+ assert_eq!(
+ parse_profile_display_name(&body).as_deref(),
+ Some("Malo Bourgon")
+ );
+ }
+
+ // --- Payload walker tests ---
+
+ fn base64url(s: &str) -> String {
+ URL_SAFE.encode(s)
+ }
+
+ #[test]
+ fn test_extract_payload_contents_simple() {
+ let text_data = base64url("Hello plain text");
+ let html_data = base64url("Hello HTML
");
+ let payload = json!({
+ "mimeType": "multipart/alternative",
+ "parts": [
+ { "mimeType": "text/plain", "body": { "data": text_data, "size": 16 } },
+ { "mimeType": "text/html", "body": { "data": html_data, "size": 18 } },
+ ]
+ });
+ let contents = extract_payload_contents(&payload);
+ assert_eq!(contents.body_text.as_deref(), Some("Hello plain text"));
+ assert_eq!(contents.body_html.as_deref(), Some("Hello HTML
"));
+ assert!(contents.parts.is_empty());
+ }
+
+ #[test]
+ fn test_extract_payload_contents_with_attachment() {
+ let text_data = base64url("Body text");
+ let payload = json!({
+ "mimeType": "multipart/mixed",
+ "parts": [
+ { "mimeType": "text/plain", "body": { "data": text_data, "size": 9 } },
+ {
+ "mimeType": "application/pdf",
+ "filename": "report.pdf",
+ "body": { "attachmentId": "ATT123", "size": 1024 },
+ "headers": [
+ { "name": "Content-Disposition", "value": "attachment; filename=\"report.pdf\"" }
+ ]
+ }
+ ]
+ });
+ let contents = extract_payload_contents(&payload);
+ assert_eq!(contents.body_text.as_deref(), Some("Body text"));
+ assert_eq!(contents.parts.len(), 1);
+ assert_eq!(contents.parts[0].filename, "report.pdf");
+ assert_eq!(contents.parts[0].content_type, "application/pdf");
+ assert_eq!(contents.parts[0].attachment_id, "ATT123");
+ assert_eq!(contents.parts[0].size, 1024);
+ assert!(!contents.parts[0].is_inline());
+ assert!(contents.parts[0].content_id.is_none());
+ }
+
+ #[test]
+ fn test_extract_payload_contents_with_inline_image() {
+ let text_data = base64url("Body");
+ let html_data = base64url("See
");
+ let payload = json!({
+ "mimeType": "multipart/related",
+ "parts": [
+ {
+ "mimeType": "multipart/alternative",
+ "parts": [
+ { "mimeType": "text/plain", "body": { "data": text_data, "size": 4 } },
+ { "mimeType": "text/html", "body": { "data": html_data, "size": 40 } },
+ ]
+ },
+ {
+ "mimeType": "image/png",
+ "filename": "photo.png",
+ "body": { "attachmentId": "INLINE1", "size": 5000 },
+ "headers": [
+ { "name": "Content-ID", "value": "" },
+ { "name": "Content-Disposition", "value": "inline; filename=\"photo.png\"" }
+ ]
+ }
+ ]
+ });
+ let contents = extract_payload_contents(&payload);
+ assert_eq!(contents.parts.len(), 1);
+ assert!(contents.parts[0].is_inline());
+ assert_eq!(
+ contents.parts[0].content_id.as_deref(),
+ Some("img1@example.com")
+ );
+ assert_eq!(contents.parts[0].filename, "photo.png");
+ }
+
+ #[test]
+ fn test_extract_payload_contents_no_filename_synthesis() {
+ let payload = json!({
+ "mimeType": "multipart/mixed",
+ "parts": [
+ { "mimeType": "text/plain", "body": { "data": base64url("hi"), "size": 2 } },
+ {
+ "mimeType": "image/jpeg",
+ "filename": "",
+ "body": { "attachmentId": "ATT_NO_NAME", "size": 500 },
+ "headers": []
+ }
+ ]
+ });
+ let contents = extract_payload_contents(&payload);
+ assert_eq!(contents.parts.len(), 1);
+ assert_eq!(contents.parts[0].filename, "part-0.jpg");
+ assert!(!contents.parts[0].is_inline());
+ }
+
+ #[test]
+ fn test_content_id_normalization() {
+ let payload = json!({
+ "mimeType": "image/png",
+ "filename": "logo.png",
+ "body": { "attachmentId": "CID_TEST", "size": 100 },
+ "headers": [
+ { "name": "Content-ID", "value": "" }
+ ]
+ });
+ let contents = extract_payload_contents(&payload);
+ assert_eq!(contents.parts.len(), 1);
+ // Angle brackets should be stripped
+ assert_eq!(
+ contents.parts[0].content_id.as_deref(),
+ Some("logo@company.com")
+ );
+ }
+
+ #[test]
+ fn test_content_id_crlf_injection_sanitized() {
+ // Content-ID is sender-controlled; CR/LF could inject MIME headers.
+ // Verify that control characters are stripped.
+ let payload = json!({
+ "mimeType": "image/png",
+ "filename": "evil.png",
+ "body": { "attachmentId": "INJECT_TEST", "size": 100 },
+ "headers": [
+ { "name": "Content-ID", "value": "" }
+ ]
+ });
+ let contents = extract_payload_contents(&payload);
+ assert_eq!(contents.parts.len(), 1);
+ // CR/LF stripped, part is still inline
+ assert!(contents.parts[0].is_inline());
+ let cid = contents.parts[0].content_id.as_deref().unwrap();
+ assert!(!cid.contains('\r'));
+ assert!(!cid.contains('\n'));
+ assert_eq!(cid, "img1@example.comX-Injected: yes");
+ }
+
+ #[test]
+ fn test_content_id_all_control_chars_becomes_none() {
+ // A Content-ID that is entirely control characters should be treated as absent,
+ // making the part a regular attachment instead of inline.
+ let payload = json!({
+ "mimeType": "image/png",
+ "filename": "weird.png",
+ "body": { "attachmentId": "EMPTY_CID", "size": 100 },
+ "headers": [
+ { "name": "Content-ID", "value": "<\r\n>" }
+ ]
+ });
+ let contents = extract_payload_contents(&payload);
+ assert_eq!(contents.parts.len(), 1);
+ assert!(!contents.parts[0].is_inline());
+ assert!(contents.parts[0].content_id.is_none());
+ }
+
+ #[test]
+ fn test_parse_profile_display_name_empty() {
+ let body = serde_json::json!({});
+ assert!(parse_profile_display_name(&body).is_none());
+ }
+
+ #[test]
+ fn test_parse_profile_display_name_empty_name() {
+ let body = serde_json::json!({
+ "names": [{ "displayName": "" }]
+ });
+ assert!(parse_profile_display_name(&body).is_none());
+ }
+
+ #[test]
+ fn test_parse_profile_display_name_no_names_array() {
+ let body = serde_json::json!({ "names": "not-an-array" });
+ assert!(parse_profile_display_name(&body).is_none());
+ }
+
+ // --- build_api_error tests ---
+
+ #[test]
+ fn test_build_api_error_parses_google_json_format() {
+ let body = r#"{"error":{"code":403,"message":"Insufficient Permission","errors":[{"reason":"insufficientPermissions","domain":"global","message":"Insufficient Permission"}]}}"#;
+ let err = build_api_error(403, body, "Test context");
+ match err {
+ GwsError::Api {
+ code,
+ message,
+ reason,
+ enable_url,
+ } => {
+ assert_eq!(code, 403);
+ assert!(message.contains("Test context"));
+ assert!(message.contains("Insufficient Permission"));
+ assert_eq!(reason, "insufficientPermissions");
+ assert!(enable_url.is_none());
+ }
+ _ => panic!("Expected GwsError::Api"),
+ }
+ }
+
+ #[test]
+ fn test_build_api_error_falls_back_to_raw_body() {
+ let err = build_api_error(500, "Internal Server Error", "Test context");
+ match err {
+ GwsError::Api {
+ code,
+ message,
+ reason,
+ ..
+ } => {
+ assert_eq!(code, 500);
+ assert!(message.contains("Internal Server Error"));
+ assert_eq!(reason, "unknown");
+ }
+ _ => panic!("Expected GwsError::Api"),
+ }
+ }
+
+ #[test]
+ fn test_build_api_error_extracts_top_level_reason() {
+ let body = r#"{"error":{"code":404,"message":"Not Found","reason":"notFound"}}"#;
+ let err = build_api_error(404, body, "ctx");
+ match err {
+ GwsError::Api { reason, .. } => assert_eq!(reason, "notFound"),
+ _ => panic!("Expected GwsError::Api"),
+ }
+ }
+
+ #[test]
+ fn test_build_api_error_access_not_configured_extracts_url() {
+ let body = r#"{"error":{"code":403,"message":"People API has not been used in project 123 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/people.googleapis.com/overview?project=123 then retry.","errors":[{"reason":"accessNotConfigured"}]}}"#;
+ let err = build_api_error(403, body, "ctx");
+ match err {
+ GwsError::Api {
+ reason, enable_url, ..
+ } => {
+ assert_eq!(reason, "accessNotConfigured");
+ assert!(enable_url.is_some());
+ assert!(enable_url
+ .unwrap()
+ .contains("console.developers.google.com"));
+ }
+ _ => panic!("Expected GwsError::Api"),
+ }
+ }
+
+ #[test]
+ fn test_attachment_with_content_id_and_disposition_attachment_is_not_inline() {
+ // Gmail gives Content-IDs to regular attachments (e.g., PDFs). A part
+ // with Content-Disposition: attachment should be classified as a regular
+ // attachment regardless of Content-ID presence.
+ let payload = json!({
+ "mimeType": "application/pdf",
+ "filename": "report.pdf",
+ "body": { "attachmentId": "PDF1", "size": 50000 },
+ "headers": [
+ { "name": "Content-Disposition", "value": "attachment; filename=\"report.pdf\"" },
+ { "name": "Content-ID", "value": "" }
+ ]
+ });
+ let contents = extract_payload_contents(&payload);
+ assert_eq!(contents.parts.len(), 1);
+ // Should be classified as regular attachment, NOT inline
+ assert!(!contents.parts[0].is_inline());
+ assert!(contents.parts[0].content_id.is_none());
+ }
+
+ #[test]
+ fn test_extract_payload_contents_does_not_recurse_into_attachments() {
+ // A message/rfc822 attachment has its own MIME subtree. The walker
+ // should NOT recurse into it — the attached message's body and parts
+ // should not leak into the top-level message.
+ let payload = json!({
+ "mimeType": "multipart/mixed",
+ "parts": [
+ {
+ "mimeType": "text/plain",
+ "body": { "data": base64url("Outer body"), "size": 10 }
+ },
+ {
+ "mimeType": "message/rfc822",
+ "filename": "attached.eml",
+ "body": { "attachmentId": "EML1", "size": 5000 },
+ "headers": [],
+ "parts": [
+ {
+ "mimeType": "text/plain",
+ "body": { "data": base64url("Inner body — should NOT be extracted"), "size": 40 }
+ },
+ {
+ "mimeType": "application/pdf",
+ "filename": "inner.pdf",
+ "body": { "attachmentId": "INNER_ATT", "size": 1000 },
+ "headers": []
+ }
+ ]
+ }
+ ]
+ });
+ let contents = extract_payload_contents(&payload);
+ // Should extract the outer body text
+ assert_eq!(contents.body_text.as_deref(), Some("Outer body"));
+ // Should have exactly one part: the message/rfc822 attachment
+ assert_eq!(contents.parts.len(), 1);
+ assert_eq!(contents.parts[0].filename, "attached.eml");
+ assert_eq!(contents.parts[0].attachment_id, "EML1");
+ // The inner body and inner attachment should NOT appear
+ assert_ne!(
+ contents.body_text.as_deref(),
+ Some("Inner body \u{2014} should NOT be extracted")
+ );
+ }
+
+ #[test]
+ fn test_header_case_insensitive() {
+ let payload = json!({
+ "mimeType": "image/gif",
+ "filename": "spacer.gif",
+ "body": { "attachmentId": "CASE_TEST", "size": 43 },
+ "headers": [
+ { "name": "content-id", "value": "" },
+ { "name": "content-disposition", "value": "inline" }
+ ]
+ });
+ let contents = extract_payload_contents(&payload);
+ assert_eq!(contents.parts.len(), 1);
+ assert!(contents.parts[0].is_inline());
+ assert_eq!(
+ contents.parts[0].content_id.as_deref(),
+ Some("spacer@example.com")
+ );
+ }
+
+ #[test]
+ fn test_filename_control_char_sanitization() {
+ let payload = json!({
+ "mimeType": "application/pdf",
+ "filename": "report\x00\x0d.pdf",
+ "body": { "attachmentId": "SANITIZE_TEST", "size": 100 },
+ "headers": []
+ });
+ let contents = extract_payload_contents(&payload);
+ assert_eq!(contents.parts.len(), 1);
+ assert_eq!(contents.parts[0].filename, "report.pdf");
+ }
+
+ // --- finalize_message MIME structure tests ---
+
+ #[test]
+ fn test_finalize_message_html_inline_creates_multipart_related() {
+ let attachments = vec![Attachment {
+ filename: "photo.png".to_string(),
+ content_type: "image/png".to_string(),
+ data: vec![0x89, 0x50, 0x4E, 0x47],
+ content_id: Some("img1@example.com".to_string()),
+ }];
+ let mb = mail_builder::MessageBuilder::new()
+ .to(MbAddress::new_address(None::<&str>, "test@example.com"))
+ .subject("test");
+ let raw = finalize_message(
+ mb,
+ "See
",
+ true,
+ &attachments,
+ )
+ .unwrap();
+
+ assert!(raw.contains("multipart/related"));
+ assert!(raw.contains("text/html"));
+ assert!(raw.contains("Content-ID: "));
+ // Should NOT be multipart/mixed since there are no regular attachments
+ assert!(!raw.contains("multipart/mixed"));
+ }
+
+ #[test]
+ fn test_finalize_message_html_inline_and_attachment() {
+ let attachments = vec![
+ Attachment {
+ filename: "photo.png".to_string(),
+ content_type: "image/png".to_string(),
+ data: vec![0x89, 0x50],
+ content_id: Some("img1@example.com".to_string()),
+ },
+ Attachment {
+ filename: "report.pdf".to_string(),
+ content_type: "application/pdf".to_string(),
+ data: b"pdf data".to_vec(),
+ content_id: None,
+ },
+ ];
+ let mb = mail_builder::MessageBuilder::new()
+ .to(MbAddress::new_address(None::<&str>, "test@example.com"))
+ .subject("test");
+ let raw = finalize_message(mb, "HTML body
", true, &attachments).unwrap();
+
+ // Should have multipart/mixed wrapping multipart/related + regular attachment
+ assert!(raw.contains("multipart/mixed"));
+ assert!(raw.contains("multipart/related"));
+ assert!(raw.contains("Content-ID: "));
+ assert!(raw.contains("report.pdf"));
+ }
+
+ #[test]
+ fn test_finalize_message_plain_text_downgrades_inline_to_attachment() {
+ let attachments = vec![Attachment {
+ filename: "photo.png".to_string(),
+ content_type: "image/png".to_string(),
+ data: vec![0x89, 0x50],
+ content_id: Some("img1@example.com".to_string()),
+ }];
+ let mb = mail_builder::MessageBuilder::new()
+ .to(MbAddress::new_address(None::<&str>, "test@example.com"))
+ .subject("test");
+ let raw = finalize_message(mb, "Plain text body", false, &attachments).unwrap();
+
+ // Should NOT use multipart/related in plain text mode
+ assert!(!raw.contains("multipart/related"));
+ // Should be a regular attachment
+ assert!(raw.contains("multipart/mixed"));
+ assert!(raw.contains("photo.png"));
+ // Content-ID should NOT appear
+ assert!(!raw.contains("Content-ID: "));
+ }
+
+ // --- parse_original_message end-to-end with parts ---
+
+ #[test]
+ fn test_parse_original_message_populates_parts() {
+ let msg = json!({
+ "threadId": "thread1",
+ "snippet": "fallback",
+ "payload": {
+ "mimeType": "multipart/mixed",
+ "headers": [
+ { "name": "From", "value": "alice@example.com" },
+ { "name": "To", "value": "bob@example.com" },
+ { "name": "Subject", "value": "Files" },
+ { "name": "Message-ID", "value": "" },
+ ],
+ "parts": [
+ {
+ "mimeType": "text/plain",
+ "body": { "data": base64url("Hello"), "size": 5 }
+ },
+ {
+ "mimeType": "application/pdf",
+ "filename": "report.pdf",
+ "body": { "attachmentId": "ATT1", "size": 2048 },
+ "headers": []
+ },
+ {
+ "mimeType": "image/png",
+ "filename": "photo.png",
+ "body": { "attachmentId": "ATT2", "size": 4096 },
+ "headers": [
+ { "name": "Content-ID", "value": "" }
+ ]
+ }
+ ]
+ }
+ });
+ let original = parse_original_message(&msg).unwrap();
+ assert_eq!(original.body_text, "Hello");
+ assert_eq!(original.parts.len(), 2);
+ // First part: regular attachment
+ assert_eq!(original.parts[0].filename, "report.pdf");
+ assert!(!original.parts[0].is_inline());
+ assert_eq!(original.parts[0].attachment_id, "ATT1");
+ // Second part: inline image
+ assert_eq!(original.parts[1].filename, "photo.png");
+ assert!(original.parts[1].is_inline());
+ assert_eq!(
+ original.parts[1].content_id.as_deref(),
+ Some("img1@example.com")
+ );
+ }
+
+ // --- finalize_message with multiple inline images ---
+
+ #[test]
+ fn test_finalize_message_html_multiple_inline_images() {
+ let attachments = vec![
+ Attachment {
+ filename: "img1.png".to_string(),
+ content_type: "image/png".to_string(),
+ data: vec![0x89, 0x50],
+ content_id: Some("img1@example.com".to_string()),
+ },
+ Attachment {
+ filename: "img2.jpg".to_string(),
+ content_type: "image/jpeg".to_string(),
+ data: vec![0xFF, 0xD8],
+ content_id: Some("img2@example.com".to_string()),
+ },
+ ];
+ let mb = mail_builder::MessageBuilder::new()
+ .to(MbAddress::new_address(None::<&str>, "test@example.com"))
+ .subject("test");
+ let raw = finalize_message(
+ mb,
+ "",
+ true,
+ &attachments,
+ )
+ .unwrap();
+
+ assert!(raw.contains("multipart/related"));
+ assert!(raw.contains("Content-ID: "));
+ assert!(raw.contains("Content-ID: "));
+ }
+
+ // --- synthesize_filename direct tests ---
+
+ #[test]
+ fn test_synthesize_filename_jpeg() {
+ assert_eq!(synthesize_filename(0, "image/jpeg"), "part-0.jpg");
+ }
+
+ #[test]
+ fn test_synthesize_filename_svg() {
+ assert_eq!(synthesize_filename(1, "image/svg+xml"), "part-1.svg");
+ }
+
+ #[test]
+ fn test_synthesize_filename_octet_stream() {
+ assert_eq!(
+ synthesize_filename(2, "application/octet-stream"),
+ "part-2.bin"
+ );
+ }
+
+ #[test]
+ fn test_synthesize_filename_no_slash() {
+ assert_eq!(synthesize_filename(0, "weirdtype"), "part-0.bin");
+ }
+
+ // --- sanitize_remote_filename edge cases ---
+
+ #[test]
+ fn test_sanitize_remote_filename_all_control_chars() {
+ // All control characters → falls back to synthesized name
+ assert_eq!(
+ sanitize_remote_filename("\x00\x01\x02", 0, "application/pdf"),
+ "part-0.pdf"
+ );
+ }
+
+ #[test]
+ fn test_sanitize_remote_filename_whitespace_only() {
+ assert_eq!(
+ sanitize_remote_filename(" ", 0, "image/png"),
+ "part-0.png"
+ );
+ }
+}
diff --git a/crates/google-workspace-cli/src/helpers/gmail/read.rs b/crates/google-workspace-cli/src/helpers/gmail/read.rs
new file mode 100644
index 00000000..c09d0858
--- /dev/null
+++ b/crates/google-workspace-cli/src/helpers/gmail/read.rs
@@ -0,0 +1,144 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use super::*;
+use std::io::{self, Write};
+
+/// Handle the `+read` subcommand.
+pub(super) async fn handle_read(
+ _doc: &crate::discovery::RestDescription,
+ matches: &ArgMatches,
+) -> Result<(), GwsError> {
+ let message_id = matches.get_one::("id").unwrap();
+
+ let dry_run = matches.get_flag("dry-run");
+
+ let original = if dry_run {
+ OriginalMessage::dry_run_placeholder(message_id)
+ } else {
+ let t = auth::get_token(&[GMAIL_READONLY_SCOPE])
+ .await
+ .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
+
+ let client = crate::client::build_client()?;
+ fetch_message_metadata(&client, &t, message_id).await?
+ };
+
+ let format = matches.get_one::("format").unwrap();
+ let show_headers = matches.get_flag("headers");
+ let use_html = matches.get_flag("html");
+
+ let mut stdout = io::stdout().lock();
+
+ if format == "json" {
+ let json_output = serde_json::to_string_pretty(&original)
+ .context("Failed to serialize message to JSON")?;
+ writeln!(stdout, "{}", json_output).context("Failed to write JSON output")?;
+ return Ok(());
+ }
+
+ if show_headers {
+ // Format structured fields into display strings for header output.
+ let from_str = original.from.to_string();
+ let to_str = format_mailbox_list(&original.to);
+ let cc_str = original
+ .cc
+ .as_ref()
+ .map(|cc| format_mailbox_list(cc))
+ .unwrap_or_default();
+
+ let headers_to_show: [(&str, &str); 5] = [
+ ("From", &from_str),
+ ("To", &to_str),
+ ("Cc", &cc_str),
+ ("Subject", &original.subject),
+ ("Date", original.date.as_deref().unwrap_or_default()),
+ ];
+ for (name, value) in headers_to_show {
+ if value.is_empty() {
+ continue;
+ }
+ // Replace newlines to prevent header spoofing in the output, then sanitize.
+ let sanitized_value = sanitize_for_terminal(&value.replace(['\r', '\n'], " "));
+ writeln!(stdout, "{}: {}", name, sanitized_value)
+ .with_context(|| format!("Failed to write '{name}' header"))?;
+ }
+ writeln!(stdout, "---").context("Failed to write header separator")?;
+ }
+
+ let body = if use_html {
+ original
+ .body_html
+ .as_deref()
+ .filter(|s| !s.trim().is_empty())
+ .unwrap_or(&original.body_text)
+ } else {
+ &original.body_text
+ };
+
+ writeln!(stdout, "{}", sanitize_for_terminal(body)).context("Failed to write message body")?;
+
+ Ok(())
+}
+
+/// Format a slice of Mailbox as a displayable comma-separated string.
+fn format_mailbox_list(mailboxes: &[Mailbox]) -> String {
+ mailboxes
+ .iter()
+ .map(|m| m.to_string())
+ .collect::>()
+ .join(", ")
+}
+
+use crate::output::sanitize_for_terminal;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_sanitize_for_terminal() {
+ let malicious = "Subject: \x1b]0;MALICIOUS\x07Hello\nWorld\r\t";
+ let sanitized = sanitize_for_terminal(malicious);
+ // ANSI escape sequences (control chars) should be removed
+ assert!(!sanitized.contains('\x1b'));
+ assert!(!sanitized.contains('\x07'));
+ // CR is also stripped (can be abused for terminal overwrite attacks)
+ assert!(!sanitized.contains('\r'));
+ // Newline and tab should be preserved
+ assert!(sanitized.contains("Hello"));
+ assert!(sanitized.contains('\n'));
+ assert!(sanitized.contains('\t'));
+ }
+
+ #[test]
+ fn test_format_mailbox_list_empty() {
+ assert_eq!(format_mailbox_list(&[]), "");
+ }
+
+ #[test]
+ fn test_format_mailbox_list_single() {
+ let mailboxes = Mailbox::parse_list("alice@example.com");
+ let result = format_mailbox_list(&mailboxes);
+ assert!(result.contains("alice@example.com"));
+ }
+
+ #[test]
+ fn test_format_mailbox_list_multiple() {
+ let mailboxes = Mailbox::parse_list("alice@example.com, Bob ");
+ let result = format_mailbox_list(&mailboxes);
+ assert!(result.contains("alice@example.com"));
+ assert!(result.contains("bob@example.com"));
+ }
+}
diff --git a/crates/google-workspace-cli/src/helpers/gmail/reply.rs b/crates/google-workspace-cli/src/helpers/gmail/reply.rs
new file mode 100644
index 00000000..6e5b8c21
--- /dev/null
+++ b/crates/google-workspace-cli/src/helpers/gmail/reply.rs
@@ -0,0 +1,1578 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use super::*;
+
+/// Handle the `+reply` and `+reply-all` subcommands.
+pub(super) async fn handle_reply(
+ doc: &crate::discovery::RestDescription,
+ matches: &ArgMatches,
+ reply_all: bool,
+) -> Result<(), GwsError> {
+ let mut config = parse_reply_args(matches)?;
+ let dry_run = matches.get_flag("dry-run");
+
+ let (original, token, self_email, client) = if dry_run {
+ (
+ OriginalMessage::dry_run_placeholder(&config.message_id),
+ None,
+ None,
+ None,
+ )
+ } else {
+ let t = auth::get_token(&[GMAIL_SCOPE])
+ .await
+ .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
+ let c = crate::client::build_client()?;
+ let orig = fetch_message_metadata(&c, &t, &config.message_id).await?;
+ config.from = resolve_sender(&c, &t, config.from.as_deref()).await?;
+ // For reply-all, always fetch the primary email for self-dedup and
+ // self-reply detection. The resolved sender may be an alias that differs from the primary
+ // address — both must be excluded from recipients. from_alias_email
+ // (extracted from config.from below) handles the alias; self_email
+ // handles the primary.
+ let self_addr = if reply_all {
+ Some(fetch_user_email(&c, &t).await?)
+ } else {
+ None
+ };
+ (orig, Some(t), self_addr, Some(c))
+ };
+
+ let self_email = self_email.as_deref();
+
+ // Determine reply recipients
+ let from_alias_email = config
+ .from
+ .as_ref()
+ .and_then(|addrs| addrs.first())
+ .map(|m| m.email.as_str());
+ let mut reply_to = if reply_all {
+ build_reply_all_recipients(
+ &original,
+ config.cc.as_deref(),
+ config.remove.as_deref(),
+ self_email,
+ from_alias_email,
+ )
+ } else {
+ Ok(ReplyRecipients {
+ to: extract_reply_to_address(&original),
+ cc: config.cc.clone(),
+ })
+ }?;
+
+ // Append extra --to recipients
+ if let Some(extra_to) = &config.extra_to {
+ reply_to.to.extend(extra_to.iter().cloned());
+ }
+
+ // Dedup across To/CC/BCC (priority: To> CC> BCC)
+ let (to, cc, bcc) =
+ dedup_recipients(&reply_to.to, reply_to.cc.as_deref(), config.bcc.as_deref());
+
+ if to.is_empty() {
+ return Err(GwsError::Validation(
+ "No To recipient remains after exclusions and --to additions".to_string(),
+ ));
+ }
+
+ let subject = build_reply_subject(&original.subject);
+ let refs = build_references_chain(&original);
+
+ let envelope = ReplyEnvelope {
+ to: &to,
+ cc: non_empty_slice(&cc),
+ bcc: non_empty_slice(&bcc),
+ from: config.from.as_deref(),
+
+ subject: &subject,
+ threading: ThreadingHeaders {
+ in_reply_to: &original.message_id,
+ references: &refs,
+ },
+ body: &config.body,
+ html: config.html,
+ };
+
+ // Fetch inline images for HTML replies only. In plain-text mode, inline
+ // images are dropped entirely — matching Gmail web, which strips them from
+ // both plain-text replies and plain-text forwards.
+ let mut all_attachments = config.attachments;
+ if let (true, Some(client), Some(token)) = (config.html, &client, &token) {
+ let inline_parts: Vec<_> = original
+ .parts
+ .iter()
+ .filter(|p| p.is_inline())
+ .cloned()
+ .collect();
+
+ fetch_and_merge_original_parts(
+ client,
+ token,
+ &config.message_id,
+ &inline_parts,
+ &mut all_attachments,
+ )
+ .await?;
+ }
+
+ let raw = create_reply_raw_message(&envelope, &original, &all_attachments)?;
+
+ super::dispatch_raw_email(
+ doc,
+ matches,
+ &raw,
+ original.thread_id.as_deref(),
+ token.as_deref(),
+ )
+ .await
+}
+
+// --- Data structures ---
+
+#[derive(Debug)]
+struct ReplyRecipients {
+ to: Vec,
+ cc: Option>,
+}
+
+struct ReplyEnvelope<'a> {
+ to: &'a [Mailbox],
+ cc: Option<&'a [Mailbox]>,
+ bcc: Option<&'a [Mailbox]>,
+ from: Option<&'a [Mailbox]>,
+ subject: &'a str,
+ threading: ThreadingHeaders<'a>,
+ body: &'a str, // Always present: --body is required for replies
+ html: bool, // When true, body content is treated as HTML
+}
+
+pub(super) struct ReplyConfig {
+ pub message_id: String,
+ pub body: String,
+ pub from: Option>,
+ pub extra_to: Option>,
+ pub cc: Option>,
+ pub bcc: Option>,
+ pub remove: Option>,
+ pub html: bool,
+ pub attachments: Vec,
+}
+
+/// Fetch the authenticated user's primary email from the Gmail profile API.
+/// Used in reply-all for self-dedup (excluding the user from recipients) and
+/// self-reply detection (switching to original-To-based addressing).
+async fn fetch_user_email(client: &reqwest::Client, token: &str) -> Result {
+ let resp = crate::client::send_with_retry(|| {
+ client
+ .get("https://gmail.googleapis.com/gmail/v1/users/me/profile")
+ .bearer_auth(token)
+ })
+ .await
+ .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch user profile: {e}")))?;
+
+ if !resp.status().is_success() {
+ let status = resp.status().as_u16();
+ let body = resp
+ .text()
+ .await
+ .unwrap_or_else(|_| "(error body unreadable)".to_string());
+ return Err(super::build_api_error(
+ status,
+ &body,
+ "Failed to fetch user profile",
+ ));
+ }
+
+ let profile: Value = resp
+ .json()
+ .await
+ .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse profile: {e}")))?;
+
+ profile
+ .get("emailAddress")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+ .ok_or_else(|| GwsError::Other(anyhow::anyhow!("Profile missing emailAddress")))
+}
+
+// --- Message construction ---
+
+fn extract_reply_to_address(original: &OriginalMessage) -> Vec {
+ match &original.reply_to {
+ Some(reply_to) => reply_to.clone(),
+ None => vec![original.from.clone()],
+ }
+}
+
+fn build_reply_all_recipients(
+ original: &OriginalMessage,
+ extra_cc: Option<&[mailbox]>,
+ remove: Option<&[mailbox]>,
+ self_email: Option<&str>,
+ from_alias: Option<&str>,
+) -> Result {
+ let excluded = collect_excluded_emails(remove, self_email, from_alias);
+
+ // When replying to your own message, the original sender (you) would be
+ // excluded from To, leaving it empty. Gmail web handles this by using the
+ // original To recipients as the reply targets instead, ignoring Reply-To.
+ // (Gmail ignores Reply-To on self-sent messages — we approximate this by
+ // checking the primary address and the current From alias.)
+ let is_self_reply = [self_email, from_alias]
+ .into_iter()
+ .flatten()
+ .any(|e| original.from.email.eq_ignore_ascii_case(e));
+
+ let (to_candidates, mut cc_candidates) = if is_self_reply {
+ // Self-reply: To = original To, CC = original CC
+ let cc = original.cc.clone().unwrap_or_default();
+ (original.to.clone(), cc)
+ } else {
+ // Normal reply: To = Reply-To or From, CC = original To + CC
+ let mut cc = original.to.clone();
+ if let Some(orig_cc) = &original.cc {
+ cc.extend(orig_cc.iter().cloned());
+ }
+ (extract_reply_to_address(original), cc)
+ };
+
+ let mut to_emails = std::collections::HashSet::new();
+ let to: Vec = to_candidates
+ .into_iter()
+ .filter(|m| {
+ let email = m.email_lowercase();
+ if email.is_empty() || excluded.contains(&email) {
+ return false;
+ }
+ to_emails.insert(email)
+ })
+ .collect();
+
+ // Add extra CC if provided
+ if let Some(extra) = extra_cc {
+ cc_candidates.extend(extra.iter().cloned());
+ }
+
+ // Filter CC: remove To recipients, excluded addresses, and duplicates
+ let mut seen = std::collections::HashSet::new();
+ let cc: Vec = cc_candidates
+ .into_iter()
+ .filter(|m| {
+ let email = m.email_lowercase();
+ !email.is_empty()
+ && !to_emails.contains(&email)
+ && !excluded.contains(&email)
+ && seen.insert(email)
+ })
+ .collect();
+
+ let cc = if cc.is_empty() { None } else { Some(cc) };
+
+ Ok(ReplyRecipients { to, cc })
+}
+
+/// Deduplicate recipients across To, CC, and BCC fields.
+///
+/// Priority: To> CC> BCC. If an email appears in multiple fields,
+/// it is kept only in the highest-priority field.
+fn dedup_recipients(
+ to: &[Mailbox],
+ cc: Option<&[mailbox]>,
+ bcc: Option<&[mailbox]>,
+) -> (Vec, Vec, Vec) {
+ use std::collections::HashSet;
+
+ let mut seen = HashSet::new();
+ let mut dedup = |mailboxes: &[Mailbox]| -> Vec {
+ mailboxes
+ .iter()
+ .filter(|m| {
+ let email = m.email_lowercase();
+ !email.is_empty() && seen.insert(email)
+ })
+ .cloned()
+ .collect()
+ };
+
+ let to_out = dedup(to);
+ let cc_out = dedup(cc.unwrap_or(&[]));
+ let bcc_out = dedup(bcc.unwrap_or(&[]));
+
+ (to_out, cc_out, bcc_out)
+}
+
+fn collect_excluded_emails(
+ remove: Option<&[mailbox]>,
+ self_email: Option<&str>,
+ from_alias: Option<&str>,
+) -> std::collections::HashSet {
+ let mut excluded = std::collections::HashSet::new();
+
+ if let Some(remove) = remove {
+ excluded.extend(
+ remove
+ .iter()
+ .map(|m| m.email_lowercase())
+ .filter(|email| !email.is_empty()),
+ );
+ }
+
+ // Exclude the user's own address and any --from alias
+ for raw in [self_email, from_alias].into_iter().flatten() {
+ let email = Mailbox::parse(raw).email_lowercase();
+ if !email.is_empty() {
+ excluded.insert(email);
+ }
+ }
+
+ excluded
+}
+
+fn build_reply_subject(original_subject: &str) -> String {
+ if original_subject.to_lowercase().starts_with("re:") {
+ original_subject.to_string()
+ } else {
+ format!("Re: {}", original_subject)
+ }
+}
+
+fn create_reply_raw_message(
+ envelope: &ReplyEnvelope,
+ original: &OriginalMessage,
+ attachments: &[Attachment],
+) -> Result {
+ let mb = mail_builder::MessageBuilder::new()
+ .to(to_mb_address_list(envelope.to))
+ .subject(envelope.subject);
+
+ let mb = apply_optional_headers(mb, envelope.from, envelope.cc, envelope.bcc);
+ let mb = set_threading_headers(mb, &envelope.threading);
+
+ let (quoted, separator) = if envelope.html {
+ (format_quoted_original_html(original), "
\r\n")
+ } else {
+ (format_quoted_original(original), "\r\n\r\n")
+ };
+ let body = format!("{}{}{}", envelope.body, separator, quoted);
+
+ finalize_message(mb, body, envelope.html, attachments)
+}
+
+fn format_quoted_original(original: &OriginalMessage) -> String {
+ let quoted_body: String = original
+ .body_text
+ .lines()
+ .map(|line| format!("> {}", line))
+ .collect::>()
+ .join("\r\n");
+
+ let attribution = match &original.date {
+ Some(date) => format!("On {}, {} wrote:", date, original.from),
+ None => format!("{} wrote:", original.from),
+ };
+ format!("{}\r\n{}", attribution, quoted_body)
+}
+
+fn format_quoted_original_html(original: &OriginalMessage) -> String {
+ let quoted_body = resolve_html_body(original);
+ let sender = format_sender_for_attribution(&original.from);
+
+ let attribution = match &original.date {
+ Some(date) => {
+ let formatted = format_date_for_attribution(date);
+ format!("On {}, {} wrote:", formatted, sender)
+ }
+ None => format!("{} wrote:", sender),
+ };
+
+ format!(
+ "\
+
\
+ {}
\
+
\
+
\
+ {}
\
+
\
+
",
+ attribution, quoted_body,
+ )
+}
+
+// --- Argument parsing ---
+
+fn parse_reply_args(matches: &ArgMatches) -> Result {
+ // try_get_one because +reply doesn't define --remove (only +reply-all does).
+ // Explicit match distinguishes "arg not defined" from unexpected errors.
+ let remove = match matches.try_get_one::("remove") {
+ Ok(val) => val
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ .map(|s| Mailbox::parse_list(&s))
+ .filter(|v| !v.is_empty()),
+ Err(clap::parser::MatchesError::UnknownArgument { .. }) => None,
+ Err(e) => {
+ return Err(GwsError::Other(anyhow::anyhow!(
+ "Unexpected error reading --remove argument: {e}"
+ )))
+ }
+ };
+
+ Ok(ReplyConfig {
+ message_id: matches.get_one::("message-id").unwrap().to_string(),
+ body: matches.get_one::("body").unwrap().to_string(),
+ from: parse_optional_mailboxes(matches, "from"),
+ extra_to: parse_optional_mailboxes(matches, "to"),
+ cc: parse_optional_mailboxes(matches, "cc"),
+ bcc: parse_optional_mailboxes(matches, "bcc"),
+ remove,
+ html: matches.get_flag("html"),
+ attachments: parse_attachments(matches)?,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::tests::{extract_header, strip_qp_soft_breaks};
+ use super::*;
+
+ #[test]
+ fn test_build_reply_subject_without_prefix() {
+ assert_eq!(build_reply_subject("Hello"), "Re: Hello");
+ }
+
+ #[test]
+ fn test_build_reply_subject_with_prefix() {
+ assert_eq!(build_reply_subject("Re: Hello"), "Re: Hello");
+ }
+
+ #[test]
+ fn test_build_reply_subject_case_insensitive() {
+ assert_eq!(build_reply_subject("RE: Hello"), "RE: Hello");
+ }
+
+ #[test]
+ fn test_create_reply_raw_message_basic() {
+ let original = OriginalMessage {
+ thread_id: Some("t1".to_string()),
+ message_id: "abc@example.com".to_string(),
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![Mailbox::parse("bob@example.com")],
+ subject: "Hello".to_string(),
+ date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
+ body_text: "Original body".to_string(),
+ ..Default::default()
+ };
+
+ let refs = build_references_chain(&original);
+ let to = vec![Mailbox::parse("alice@example.com")];
+ let envelope = ReplyEnvelope {
+ to: &to,
+ cc: None,
+ bcc: None,
+ from: None,
+ subject: "Re: Hello",
+ threading: ThreadingHeaders {
+ in_reply_to: &original.message_id,
+ references: &refs,
+ },
+ body: "My reply",
+ html: false,
+ };
+ let raw = create_reply_raw_message(&envelope, &original, &[]).unwrap();
+
+ let to_header = extract_header(&raw, "To").unwrap();
+ assert!(to_header.contains("alice@example.com"));
+ assert!(extract_header(&raw, "Subject")
+ .unwrap()
+ .contains("Re: Hello"));
+ assert!(extract_header(&raw, "In-Reply-To")
+ .unwrap()
+ .contains("abc@example.com"));
+ assert!(raw.contains("text/plain"));
+ assert!(raw.contains("My reply"));
+ assert!(raw.contains("> Original body"));
+ }
+
+ #[test]
+ fn test_create_reply_raw_message_with_all_optional_headers() {
+ let original = OriginalMessage {
+ thread_id: Some("t1".to_string()),
+ message_id: "abc@example.com".to_string(),
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![Mailbox::parse("bob@example.com")],
+ subject: "Hello".to_string(),
+ date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
+ body_text: "Original body".to_string(),
+ ..Default::default()
+ };
+
+ let refs = build_references_chain(&original);
+ let to = vec![Mailbox::parse("alice@example.com")];
+ let cc = vec![Mailbox::parse("carol@example.com")];
+ let bcc = vec![Mailbox::parse("secret@example.com")];
+ let from = Mailbox::parse_list("alias@example.com");
+ let envelope = ReplyEnvelope {
+ to: &to,
+ cc: Some(&cc),
+ bcc: Some(&bcc),
+ from: Some(&from),
+ subject: "Re: Hello",
+ threading: ThreadingHeaders {
+ in_reply_to: &original.message_id,
+ references: &refs,
+ },
+ body: "Reply with all headers",
+ html: false,
+ };
+ let raw = create_reply_raw_message(&envelope, &original, &[]).unwrap();
+
+ assert!(extract_header(&raw, "Cc")
+ .unwrap()
+ .contains("carol@example.com"));
+ assert!(extract_header(&raw, "Bcc")
+ .unwrap()
+ .contains("secret@example.com"));
+ assert!(extract_header(&raw, "From")
+ .unwrap()
+ .contains("alias@example.com"));
+ }
+
+ #[test]
+ fn test_build_reply_all_recipients() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![
+ Mailbox::parse("bob@example.com"),
+ Mailbox::parse("carol@example.com"),
+ ],
+ cc: Some(vec![Mailbox::parse("dave@example.com")]),
+ subject: "Hello".to_string(),
+ ..Default::default()
+ };
+
+ let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
+ assert_eq!(recipients.to.len(), 1);
+ assert_eq!(recipients.to[0].email, "alice@example.com");
+ let cc = recipients.cc.unwrap();
+ assert!(cc.iter().any(|m| m.email == "bob@example.com"));
+ assert!(cc.iter().any(|m| m.email == "carol@example.com"));
+ assert!(cc.iter().any(|m| m.email == "dave@example.com"));
+ // Sender should not be in CC
+ assert!(!cc.iter().any(|m| m.email == "alice@example.com"));
+ }
+
+ #[test]
+ fn test_build_reply_all_with_remove() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![
+ Mailbox::parse("bob@example.com"),
+ Mailbox::parse("carol@example.com"),
+ ],
+ subject: "Hello".to_string(),
+ ..Default::default()
+ };
+
+ let remove = Mailbox::parse_list("carol@example.com");
+ let recipients =
+ build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap();
+ let cc = recipients.cc.unwrap();
+ assert!(cc.iter().any(|m| m.email == "bob@example.com"));
+ assert!(!cc.iter().any(|m| m.email == "carol@example.com"));
+ }
+
+ #[test]
+ fn test_build_reply_all_remove_primary_returns_empty_to() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![Mailbox::parse("bob@example.com")],
+ subject: "Hello".to_string(),
+ ..Default::default()
+ };
+
+ let remove = Mailbox::parse_list("alice@example.com");
+ let recipients =
+ build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap();
+ assert!(recipients.to.is_empty());
+ }
+
+ #[test]
+ fn test_reply_all_excludes_from_alias_from_cc() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("sender@example.com"),
+ to: vec![
+ Mailbox::parse("sales@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ],
+ cc: Some(vec![Mailbox::parse("carol@example.com")]),
+ subject: "Hello".to_string(),
+ ..Default::default()
+ };
+
+ let recipients = build_reply_all_recipients(
+ &original,
+ None,
+ None,
+ Some("me@example.com"),
+ Some("sales@example.com"),
+ )
+ .unwrap();
+ let cc = recipients.cc.unwrap();
+
+ assert!(!cc.iter().any(|m| m.email == "sales@example.com"));
+ assert!(cc.iter().any(|m| m.email == "bob@example.com"));
+ assert!(cc.iter().any(|m| m.email == "carol@example.com"));
+ }
+
+ #[test]
+ fn test_build_reply_all_from_alias_is_self_reply() {
+ // When from_alias matches original.from, this is a self-reply.
+ // To should be the original To recipients, not empty.
+ let original = OriginalMessage {
+ from: Mailbox::parse("sales@example.com"),
+ to: vec![Mailbox::parse("bob@example.com")],
+ subject: "Hello".to_string(),
+ ..Default::default()
+ };
+
+ let recipients = build_reply_all_recipients(
+ &original,
+ None,
+ None,
+ Some("me@example.com"),
+ Some("sales@example.com"),
+ )
+ .unwrap();
+ assert_eq!(recipients.to.len(), 1);
+ assert_eq!(recipients.to[0].email, "bob@example.com");
+ }
+
+ fn make_reply_matches(args: &[&str]) -> ArgMatches {
+ let cmd = Command::new("test")
+ .arg(Arg::new("message-id").long("message-id"))
+ .arg(Arg::new("body").long("body"))
+ .arg(Arg::new("from").long("from"))
+ .arg(Arg::new("to").long("to"))
+ .arg(Arg::new("cc").long("cc"))
+ .arg(Arg::new("bcc").long("bcc"))
+ .arg(Arg::new("remove").long("remove"))
+ .arg(Arg::new("html").long("html").action(ArgAction::SetTrue))
+ .arg(
+ Arg::new("attach")
+ .short('a')
+ .long("attach")
+ .action(ArgAction::Append),
+ )
+ .arg(
+ Arg::new("dry-run")
+ .long("dry-run")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue));
+ cmd.try_get_matches_from(args).unwrap()
+ }
+
+ #[test]
+ fn test_parse_reply_args() {
+ let matches = make_reply_matches(&["test", "--message-id", "abc123", "--body", "My reply"]);
+ let config = parse_reply_args(&matches).unwrap();
+ assert_eq!(config.message_id, "abc123");
+ assert_eq!(config.body, "My reply");
+ assert!(config.extra_to.is_none());
+ assert!(config.cc.is_none());
+ assert!(config.bcc.is_none());
+ assert!(config.remove.is_none());
+ }
+
+ #[test]
+ fn test_parse_reply_args_with_all_options() {
+ let matches = make_reply_matches(&[
+ "test",
+ "--message-id",
+ "abc123",
+ "--body",
+ "Reply",
+ "--to",
+ "dave@example.com",
+ "--cc",
+ "extra@example.com",
+ "--bcc",
+ "secret@example.com",
+ "--remove",
+ "unwanted@example.com",
+ ]);
+ let config = parse_reply_args(&matches).unwrap();
+ assert_eq!(
+ config.extra_to.as_ref().unwrap()[0].email,
+ "dave@example.com"
+ );
+ assert_eq!(config.cc.as_ref().unwrap()[0].email, "extra@example.com");
+ assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com");
+ assert_eq!(
+ config.remove.as_ref().unwrap()[0].email,
+ "unwanted@example.com"
+ );
+
+ // Whitespace-only values become None
+ let matches = make_reply_matches(&[
+ "test",
+ "--message-id",
+ "abc123",
+ "--body",
+ "Reply",
+ "--to",
+ " ",
+ "--cc",
+ "",
+ "--bcc",
+ " ",
+ ]);
+ let config = parse_reply_args(&matches).unwrap();
+ assert!(config.extra_to.is_none());
+ assert!(config.cc.is_none());
+ assert!(config.bcc.is_none());
+ }
+
+ #[test]
+ fn test_parse_reply_args_html_flag() {
+ let matches = make_reply_matches(&[
+ "test",
+ "--message-id",
+ "abc123",
+ "--body",
+ "Bold",
+ "--html",
+ ]);
+ let config = parse_reply_args(&matches).unwrap();
+ assert!(config.html);
+
+ // Default is false
+ let matches =
+ make_reply_matches(&["test", "--message-id", "abc123", "--body", "Plain reply"]);
+ let config = parse_reply_args(&matches).unwrap();
+ assert!(!config.html);
+ }
+
+ #[test]
+ fn test_parse_reply_args_without_remove_defined() {
+ // Simulates +reply which doesn't define --remove (only +reply-all does).
+ let cmd = Command::new("test")
+ .arg(Arg::new("message-id").long("message-id"))
+ .arg(Arg::new("body").long("body"))
+ .arg(Arg::new("from").long("from"))
+ .arg(Arg::new("to").long("to"))
+ .arg(Arg::new("cc").long("cc"))
+ .arg(Arg::new("bcc").long("bcc"))
+ .arg(Arg::new("html").long("html").action(ArgAction::SetTrue))
+ .arg(
+ Arg::new("attach")
+ .short('a')
+ .long("attach")
+ .action(ArgAction::Append),
+ );
+ let matches = cmd
+ .try_get_matches_from(&["test", "--message-id", "abc", "--body", "hi"])
+ .unwrap();
+ let config = parse_reply_args(&matches).unwrap();
+ assert!(config.remove.is_none());
+ }
+
+ #[test]
+ fn test_extract_reply_to_address_falls_back_to_from() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("Alice "),
+ ..Default::default()
+ };
+ let addrs = extract_reply_to_address(&original);
+ assert_eq!(addrs.len(), 1);
+ assert_eq!(addrs[0].email, "alice@example.com");
+ assert_eq!(addrs[0].name.as_deref(), Some("Alice"));
+ }
+
+ #[test]
+ fn test_extract_reply_to_address_prefers_reply_to() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("Alice "),
+ reply_to: Some(vec![Mailbox::parse("list@example.com")]),
+ ..Default::default()
+ };
+ let addrs = extract_reply_to_address(&original);
+ assert_eq!(addrs.len(), 1);
+ assert_eq!(addrs[0].email, "list@example.com");
+ }
+
+ #[test]
+ fn test_remove_does_not_match_substring() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("sender@example.com"),
+ to: vec![
+ Mailbox::parse("ann@example.com"),
+ Mailbox::parse("joann@example.com"),
+ ],
+ ..Default::default()
+ };
+ let remove = Mailbox::parse_list("ann@example.com");
+ let recipients =
+ build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap();
+ let cc = recipients.cc.unwrap();
+ // joann@example.com should remain, ann@example.com should be removed
+ assert_eq!(cc.len(), 1);
+ assert_eq!(cc[0].email, "joann@example.com");
+ }
+
+ #[test]
+ fn test_reply_all_uses_reply_to_for_to() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ reply_to: Some(vec![Mailbox::parse("list@example.com")]),
+ to: vec![Mailbox::parse("bob@example.com")],
+ ..Default::default()
+ };
+ let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
+ assert_eq!(recipients.to[0].email, "list@example.com");
+ let cc = recipients.cc.unwrap();
+ assert!(cc.iter().any(|m| m.email == "bob@example.com"));
+ // list@example.com is in To, should not duplicate in CC
+ assert!(!cc.iter().any(|m| m.email == "list@example.com"));
+ }
+
+ #[test]
+ fn test_sender_with_display_name_excluded_from_cc() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("Alice "),
+ to: vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ],
+ ..Default::default()
+ };
+ let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
+ assert_eq!(recipients.to[0].email, "alice@example.com");
+ let cc = recipients.cc.unwrap();
+ assert_eq!(cc.len(), 1);
+ assert_eq!(cc[0].email, "bob@example.com");
+ }
+
+ #[test]
+ fn test_remove_with_display_name_format() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("sender@example.com"),
+ to: vec![
+ Mailbox::parse("bob@example.com"),
+ Mailbox::parse("carol@example.com"),
+ ],
+ ..Default::default()
+ };
+ let remove = Mailbox::parse_list("Carol ");
+ let recipients =
+ build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap();
+ let cc = recipients.cc.unwrap();
+ assert_eq!(cc.len(), 1);
+ assert_eq!(cc[0].email, "bob@example.com");
+ }
+
+ #[test]
+ fn test_reply_all_with_extra_cc() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![Mailbox::parse("bob@example.com")],
+ ..Default::default()
+ };
+ let extra_cc = Mailbox::parse_list("extra@example.com");
+ let recipients =
+ build_reply_all_recipients(&original, Some(&extra_cc), None, None, None).unwrap();
+ let cc = recipients.cc.unwrap();
+ assert!(cc.iter().any(|m| m.email == "bob@example.com"));
+ assert!(cc.iter().any(|m| m.email == "extra@example.com"));
+ }
+
+ #[test]
+ fn test_reply_all_cc_none_when_all_filtered() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![Mailbox::parse("alice@example.com")],
+ ..Default::default()
+ };
+ let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
+ assert!(recipients.cc.is_none());
+ }
+
+ #[test]
+ fn test_case_insensitive_sender_exclusion() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("Alice@Example.COM"),
+ to: vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ],
+ ..Default::default()
+ };
+ let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
+ let cc = recipients.cc.unwrap();
+ assert_eq!(cc.len(), 1);
+ assert_eq!(cc[0].email, "bob@example.com");
+ }
+
+ #[test]
+ fn test_reply_all_multi_address_reply_to_deduplicates_cc() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ reply_to: Some(vec![
+ Mailbox::parse("list@example.com"),
+ Mailbox::parse("owner@example.com"),
+ ]),
+ to: vec![
+ Mailbox::parse("bob@example.com"),
+ Mailbox::parse("list@example.com"),
+ ],
+ cc: Some(vec![
+ Mailbox::parse("owner@example.com"),
+ Mailbox::parse("dave@example.com"),
+ ]),
+ ..Default::default()
+ };
+ let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
+ assert_eq!(recipients.to.len(), 2);
+ assert_eq!(recipients.to[0].email, "list@example.com");
+ assert_eq!(recipients.to[1].email, "owner@example.com");
+ let cc = recipients.cc.unwrap();
+ assert!(cc.iter().any(|m| m.email == "bob@example.com"));
+ assert!(cc.iter().any(|m| m.email == "dave@example.com"));
+ assert!(!cc.iter().any(|m| m.email == "list@example.com"));
+ assert!(!cc.iter().any(|m| m.email == "owner@example.com"));
+ }
+
+ #[test]
+ fn test_reply_all_with_quoted_comma_display_name() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("sender@example.com"),
+ to: Mailbox::parse_list(r#""Doe, John" , alice@example.com"#),
+ ..Default::default()
+ };
+ let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
+ let cc = recipients.cc.unwrap();
+ assert!(cc.iter().any(|m| m.email == "john@example.com"));
+ assert!(cc.iter().any(|m| m.email == "alice@example.com"));
+ }
+
+ #[test]
+ fn test_remove_with_quoted_comma_display_name() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("sender@example.com"),
+ to: Mailbox::parse_list(r#""Doe, John" , alice@example.com"#),
+ ..Default::default()
+ };
+ let remove = Mailbox::parse_list("john@example.com");
+ let recipients = build_reply_all_recipients(&original, None, Some(&remove), None, None);
+ let cc = recipients.unwrap().cc.unwrap();
+ assert!(!cc.iter().any(|m| m.email == "john@example.com"));
+ assert!(cc.iter().any(|m| m.email == "alice@example.com"));
+ }
+
+ #[test]
+ fn test_reply_all_excludes_self_email() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![
+ Mailbox::parse("me@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ],
+ ..Default::default()
+ };
+ let recipients =
+ build_reply_all_recipients(&original, None, None, Some("me@example.com"), None)
+ .unwrap();
+ let cc = recipients.cc.unwrap();
+ assert!(cc.iter().any(|m| m.email == "bob@example.com"));
+ assert!(!cc.iter().any(|m| m.email == "me@example.com"));
+ }
+
+ #[test]
+ fn test_reply_all_excludes_self_case_insensitive() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![
+ Mailbox::parse("Me@Example.COM"),
+ Mailbox::parse("bob@example.com"),
+ ],
+ ..Default::default()
+ };
+ let recipients =
+ build_reply_all_recipients(&original, None, None, Some("me@example.com"), None)
+ .unwrap();
+ let cc = recipients.cc.unwrap();
+ assert!(cc.iter().any(|m| m.email == "bob@example.com"));
+ assert!(!cc.iter().any(|m| m.email_lowercase() == "me@example.com"));
+ }
+
+ #[test]
+ fn test_reply_all_deduplicates_cc() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![Mailbox::parse("bob@example.com")],
+ cc: Some(vec![
+ Mailbox::parse("bob@example.com"),
+ Mailbox::parse("carol@example.com"),
+ ]),
+ ..Default::default()
+ };
+ let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
+ let cc = recipients.cc.unwrap();
+ assert_eq!(
+ cc.iter().filter(|m| m.email == "bob@example.com").count(),
+ 1
+ );
+ assert!(cc.iter().any(|m| m.email == "carol@example.com"));
+ }
+
+ // --- self-reply tests ---
+
+ #[test]
+ fn test_reply_all_to_own_message_puts_original_to_in_to() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("me@example.com"),
+ to: vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ],
+ cc: Some(vec![Mailbox::parse("carol@example.com")]),
+ ..Default::default()
+ };
+ let recipients =
+ build_reply_all_recipients(&original, None, None, Some("me@example.com"), None)
+ .unwrap();
+ // To should be the original To recipients, not the original sender
+ assert_eq!(recipients.to.len(), 2);
+ assert!(recipients.to.iter().any(|m| m.email == "alice@example.com"));
+ assert!(recipients.to.iter().any(|m| m.email == "bob@example.com"));
+ // CC should be the original CC
+ let cc = recipients.cc.unwrap();
+ assert_eq!(cc.len(), 1);
+ assert!(cc.iter().any(|m| m.email == "carol@example.com"));
+ }
+
+ #[test]
+ fn test_reply_all_to_own_message_detected_via_alias() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alias@work.com"),
+ to: vec![Mailbox::parse("alice@example.com")],
+ ..Default::default()
+ };
+ // self_email is primary, from_alias matches the original sender
+ let recipients = build_reply_all_recipients(
+ &original,
+ None,
+ None,
+ Some("me@gmail.com"),
+ Some("alias@work.com"),
+ )
+ .unwrap();
+ assert_eq!(recipients.to.len(), 1);
+ assert_eq!(recipients.to[0].email, "alice@example.com");
+ }
+
+ #[test]
+ fn test_reply_all_to_own_message_excludes_self_from_original_to() {
+ // You sent to yourself + Alice (e.g. a note-to-self CC'd to someone)
+ let original = OriginalMessage {
+ from: Mailbox::parse("me@example.com"),
+ to: vec![
+ Mailbox::parse("me@example.com"),
+ Mailbox::parse("alice@example.com"),
+ ],
+ ..Default::default()
+ };
+ let recipients =
+ build_reply_all_recipients(&original, None, None, Some("me@example.com"), None)
+ .unwrap();
+ // Self should still be excluded from To
+ assert_eq!(recipients.to.len(), 1);
+ assert_eq!(recipients.to[0].email, "alice@example.com");
+ }
+
+ #[test]
+ fn test_reply_all_to_own_message_ignores_reply_to() {
+ // Gmail web ignores Reply-To on self-sent messages. Verify that
+ // self-reply uses original.to, not Reply-To.
+ let original = OriginalMessage {
+ from: Mailbox::parse("me@example.com"),
+ to: vec![Mailbox::parse("alice@example.com")],
+ reply_to: Some(vec![Mailbox::parse("list@example.com")]),
+ ..Default::default()
+ };
+ let recipients =
+ build_reply_all_recipients(&original, None, None, Some("me@example.com"), None)
+ .unwrap();
+ assert_eq!(recipients.to.len(), 1);
+ assert_eq!(recipients.to[0].email, "alice@example.com");
+ // No CC — Reply-To address should not appear anywhere
+ assert!(recipients.cc.is_none());
+ }
+
+ // --- dedup_recipients tests ---
+
+ #[test]
+ fn test_dedup_no_overlap() {
+ let to = vec![Mailbox::parse("alice@example.com")];
+ let cc = vec![Mailbox::parse("bob@example.com")];
+ let bcc = vec![Mailbox::parse("carol@example.com")];
+ let (to_out, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc));
+ assert_eq!(to_out[0].email, "alice@example.com");
+ assert_eq!(cc_out[0].email, "bob@example.com");
+ assert_eq!(bcc_out[0].email, "carol@example.com");
+ }
+
+ #[test]
+ fn test_dedup_to_wins_over_cc() {
+ let to = vec![Mailbox::parse("alice@example.com")];
+ let cc = vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ];
+ let (to_out, cc_out, _) = dedup_recipients(&to, Some(&cc), None);
+ assert_eq!(to_out[0].email, "alice@example.com");
+ assert_eq!(cc_out.len(), 1);
+ assert_eq!(cc_out[0].email, "bob@example.com");
+ }
+
+ #[test]
+ fn test_dedup_to_wins_over_bcc() {
+ let to = vec![Mailbox::parse("alice@example.com")];
+ let bcc = vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("carol@example.com"),
+ ];
+ let (to_out, _, bcc_out) = dedup_recipients(&to, None, Some(&bcc));
+ assert_eq!(to_out[0].email, "alice@example.com");
+ assert_eq!(bcc_out.len(), 1);
+ assert_eq!(bcc_out[0].email, "carol@example.com");
+ }
+
+ #[test]
+ fn test_dedup_cc_wins_over_bcc() {
+ let to = vec![Mailbox::parse("alice@example.com")];
+ let cc = vec![Mailbox::parse("bob@example.com")];
+ let bcc = vec![
+ Mailbox::parse("bob@example.com"),
+ Mailbox::parse("carol@example.com"),
+ ];
+ let (_, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc));
+ assert_eq!(cc_out[0].email, "bob@example.com");
+ assert_eq!(bcc_out.len(), 1);
+ assert_eq!(bcc_out[0].email, "carol@example.com");
+ }
+
+ #[test]
+ fn test_dedup_all_three_overlap() {
+ let to = vec![Mailbox::parse("alice@example.com")];
+ let cc = vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ];
+ let bcc = vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("bob@example.com"),
+ Mailbox::parse("carol@example.com"),
+ ];
+ let (to_out, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc));
+ assert_eq!(to_out[0].email, "alice@example.com");
+ assert_eq!(cc_out[0].email, "bob@example.com");
+ assert_eq!(bcc_out[0].email, "carol@example.com");
+ }
+
+ #[test]
+ fn test_dedup_case_insensitive() {
+ let to = vec![Mailbox::parse("Alice@Example.COM")];
+ let cc = vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ];
+ let (to_out, cc_out, _) = dedup_recipients(&to, Some(&cc), None);
+ assert_eq!(to_out[0].email, "Alice@Example.COM");
+ assert_eq!(cc_out.len(), 1);
+ assert_eq!(cc_out[0].email, "bob@example.com");
+ }
+
+ #[test]
+ fn test_dedup_bcc_fully_overlaps_returns_empty() {
+ let to = vec![Mailbox::parse("alice@example.com")];
+ let cc = vec![Mailbox::parse("bob@example.com")];
+ let bcc = vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ];
+ let (_, _, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc));
+ assert!(bcc_out.is_empty());
+ }
+
+ #[test]
+ fn test_dedup_with_display_names() {
+ let to = vec![Mailbox::parse("Alice ")];
+ let cc = vec![
+ Mailbox::parse("alice@example.com"),
+ Mailbox::parse("bob@example.com"),
+ ];
+ let (to_out, cc_out, _) = dedup_recipients(&to, Some(&cc), None);
+ assert_eq!(to_out[0].email, "alice@example.com");
+ assert_eq!(to_out[0].name.as_deref(), Some("Alice"));
+ assert_eq!(cc_out.len(), 1);
+ assert_eq!(cc_out[0].email, "bob@example.com");
+ }
+
+ #[test]
+ fn test_dedup_intro_pattern() {
+ let to = vec![Mailbox::parse("bob@example.com")];
+ let cc = vec![Mailbox::parse("bob@example.com")];
+ let bcc = vec![Mailbox::parse("alice@example.com")];
+ let (to_out, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc));
+ assert_eq!(to_out[0].email, "bob@example.com");
+ assert!(cc_out.is_empty());
+ assert_eq!(bcc_out[0].email, "alice@example.com");
+ }
+
+ #[test]
+ fn test_dedup_simple_reply_no_cc_bcc() {
+ let to = vec![Mailbox::parse("alice@example.com")];
+ let (to_out, cc_out, bcc_out) = dedup_recipients(&to, None, None);
+ assert_eq!(to_out.len(), 1);
+ assert_eq!(to_out[0].email, "alice@example.com");
+ assert!(cc_out.is_empty());
+ assert!(bcc_out.is_empty());
+ }
+
+ // --- format_quoted_original (plain text) ---
+
+ #[test]
+ fn test_format_quoted_original() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
+ body_text: "Line one\nLine two\nLine three".to_string(),
+ ..Default::default()
+ };
+ let quoted = format_quoted_original(&original);
+ assert!(quoted.contains("On Mon, 1 Jan 2026 00:00:00 +0000, alice@example.com wrote:"));
+ assert!(quoted.contains("> Line one"));
+ assert!(quoted.contains("> Line two"));
+ assert!(quoted.contains("> Line three"));
+ }
+
+ #[test]
+ fn test_format_quoted_original_empty_body() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ date: Some("Mon, 1 Jan 2026".to_string()),
+ ..Default::default()
+ };
+ let quoted = format_quoted_original(&original);
+ assert!(quoted.contains("alice@example.com wrote:"));
+ // Empty body produces no quoted lines
+ assert!(quoted.ends_with("wrote:\r\n"));
+ }
+
+ #[test]
+ fn test_format_quoted_original_missing_date() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ date: None,
+ body_text: "Hello".to_string(),
+ ..Default::default()
+ };
+ let quoted = format_quoted_original(&original);
+ assert!(quoted.starts_with("alice@example.com wrote:"));
+ assert!(!quoted.contains("On "));
+ assert!(quoted.contains("> Hello"));
+ }
+
+ // --- end-to-end --to behavioral tests ---
+
+ #[test]
+ fn test_extra_to_appears_in_raw_message() {
+ let original = OriginalMessage {
+ thread_id: Some("t1".to_string()),
+ message_id: "abc@example.com".to_string(),
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![Mailbox::parse("me@example.com")],
+ subject: "Hello".to_string(),
+ date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
+ body_text: "Original".to_string(),
+ ..Default::default()
+ };
+
+ let mut to = extract_reply_to_address(&original);
+ to.push(Mailbox::parse("dave@example.com"));
+
+ let (to, cc, bcc) = dedup_recipients(&to, None, None);
+
+ let refs = build_references_chain(&original);
+ let envelope = ReplyEnvelope {
+ to: &to,
+ cc: non_empty_slice(&cc),
+ bcc: non_empty_slice(&bcc),
+ from: None,
+ subject: "Re: Hello",
+ threading: ThreadingHeaders {
+ in_reply_to: &original.message_id,
+ references: &refs,
+ },
+ body: "Adding Dave",
+ html: false,
+ };
+ let raw = create_reply_raw_message(&envelope, &original, &[]).unwrap();
+
+ let to_header = extract_header(&raw, "To").unwrap();
+ assert!(to_header.contains("alice@example.com"));
+ assert!(to_header.contains("dave@example.com"));
+ }
+
+ #[test]
+ fn test_intro_pattern_raw_message() {
+ let original = OriginalMessage {
+ thread_id: Some("t1".to_string()),
+ message_id: "abc@example.com".to_string(),
+ from: Mailbox::parse("alice@example.com"),
+ to: vec![Mailbox::parse("me@example.com")],
+ cc: Some(vec![Mailbox::parse("bob@example.com")]),
+ subject: "Intro".to_string(),
+ date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
+ body_text: "Meet Bob".to_string(),
+ ..Default::default()
+ };
+
+ // build_reply_all_recipients with --remove alice, self=me
+ let remove = Mailbox::parse_list("alice@example.com");
+ let recipients = build_reply_all_recipients(
+ &original,
+ None,
+ Some(&remove),
+ Some("me@example.com"),
+ None,
+ )
+ .unwrap();
+
+ // To is empty (alice removed)
+ assert!(recipients.to.is_empty());
+
+ // Append --to bob
+ let to = vec![Mailbox::parse("bob@example.com")];
+
+ // Dedup with --bcc alice
+ let bcc = vec![Mailbox::parse("alice@example.com")];
+ let (to, cc, bcc) = dedup_recipients(&to, recipients.cc.as_deref(), Some(&bcc));
+
+ let refs = build_references_chain(&original);
+ let envelope = ReplyEnvelope {
+ to: &to,
+ cc: non_empty_slice(&cc),
+ bcc: non_empty_slice(&bcc),
+ from: None,
+ subject: "Re: Intro",
+ threading: ThreadingHeaders {
+ in_reply_to: &original.message_id,
+ references: &refs,
+ },
+ body: "Hi Bob, nice to meet you!",
+ html: false,
+ };
+ let raw = create_reply_raw_message(&envelope, &original, &[]).unwrap();
+
+ let to_header = extract_header(&raw, "To").unwrap();
+ assert!(to_header.contains("bob@example.com"));
+ assert!(extract_header(&raw, "Bcc")
+ .unwrap()
+ .contains("alice@example.com"));
+ assert!(raw.contains("Hi Bob, nice to meet you!"));
+ }
+
+ // --- HTML mode tests ---
+
+ #[test]
+ fn test_format_quoted_original_html_with_html_body() {
+ let original = OriginalMessage {
+ from: Mailbox::parse("alice@example.com"),
+ date: Some("Mon, 1 Jan 2026".to_string()),
+ body_text: "plain fallback".to_string(),
+ body_html: Some("Rich content
".to_string()),
+ ..Default::default()
+ };
+ let html = format_quoted_original_html(&original);
+ assert!(html.contains("gmail_quote"));
+ assert!(html.contains("Rich content
"));
+ assert!(!html.contains("plain fallback"));
+ assert!(
+ html.contains("