1use std::fmt::Write as _;
13
14use mail_parser::{Addr, Address, HeaderValue, Message, MessageParser, MimeHeaders};
15#[cfg(test)]
16use mail_parser::{ContentType, Encoding, Header, HeaderName, MessagePart, PartType};
17
18use crate::gmail::attachments::extract_attachments;
19
20pub(crate) fn render_markdown(raw: &[u8], fold_quotes: bool) -> String {
36 render(raw, fold_quotes, None)
37}
38
39pub(crate) fn render_draft_markdown(raw: &[u8], fold_quotes: bool, draft_id: &str) -> String {
48 render(raw, fold_quotes, Some(draft_id))
49}
50
51fn render(raw: &[u8], fold_quotes: bool, draft_id: Option<&str>) -> String {
54 let Some(message) = MessageParser::default().parse(raw) else {
55 return "*(unable to parse this message)*\n".to_string();
56 };
57
58 let mut out = String::new();
59 let _ = writeln!(out, "# {}\n", message.subject().unwrap_or("(no subject)"));
60
61 if let Some(draft_id) = draft_id {
62 write_header(&mut out, "Draft-Id", Some(draft_id.to_string()));
63 }
64 write_header(&mut out, "From", format_address(message.from()));
65 write_header(&mut out, "To", format_address(message.to()));
66 write_header(&mut out, "Cc", format_address(message.cc()));
67 if draft_id.is_some() {
68 write_header(&mut out, "Bcc", format_address(message.bcc()));
69 }
70 write_header(
71 &mut out,
72 "Date",
73 message.date().map(mail_parser::DateTime::to_rfc822),
74 );
75 write_header(
76 &mut out,
77 "Message-Id",
78 message.message_id().map(str::to_string),
79 );
80 write_header(
81 &mut out,
82 "In-Reply-To",
83 format_header_value(message.in_reply_to()),
84 );
85 write_header(
86 &mut out,
87 "References",
88 format_header_value(message.references()),
89 );
90
91 out.push('\n');
92 out.push_str(body_markdown(&message, fold_quotes).trim_end());
93 out.push('\n');
94
95 let attachments = extract_attachments(raw);
96 if !attachments.is_empty() {
97 out.push_str("\n## Attachments\n\n");
98 for attachment in &attachments {
99 let _ = writeln!(out, "- {}", attachment.filename);
100 }
101 }
102
103 out
104}
105
106fn write_header(out: &mut String, label: &str, value: Option<String>) {
111 if let Some(value) = value {
112 let _ = writeln!(out, "- **{label}:** {value}");
113 }
114}
115
116fn format_address(address: Option<&Address>) -> Option<String> {
119 let address = address?;
120 let formatted: Vec<String> = address.iter().map(format_addr).collect();
121 (!formatted.is_empty()).then(|| formatted.join(", "))
122}
123
124fn format_addr(addr: &Addr) -> String {
125 match (&addr.name, &addr.address) {
126 (Some(name), Some(email)) => format!("{name} <{email}>"),
127 (Some(name), None) => name.to_string(),
128 (None, Some(email)) => email.to_string(),
129 (None, None) => String::new(),
130 }
131}
132
133fn format_header_value(value: &HeaderValue) -> Option<String> {
136 match value {
137 HeaderValue::Text(text) => Some(text.to_string()),
138 HeaderValue::TextList(list) if !list.is_empty() => {
139 Some(list.iter().map(AsRef::as_ref).collect::<Vec<_>>().join(" "))
140 }
141 _ => None,
142 }
143}
144
145fn body_markdown(message: &Message, fold_quotes: bool) -> String {
161 if message
162 .text_part(0)
163 .is_some_and(|part| part.is_content_type("text", "plain"))
164 {
165 if let Some(text) = message.body_text(0) {
166 return finish_body(text.into_owned(), fold_quotes);
167 }
168 }
169 if let Some(html) = message.body_html(0) {
170 let converted = htmd::convert(&html).unwrap_or_else(|_| html.into_owned());
171 return finish_body(converted, fold_quotes);
172 }
173 if let Some(text) = message.body_text(0) {
174 return finish_body(text.into_owned(), fold_quotes);
175 }
176 "*(no body)*".to_string()
177}
178
179fn finish_body(body: String, fold_quotes: bool) -> String {
184 if fold_quotes {
185 fold_quoted_lines(&body)
186 } else {
187 body
188 }
189}
190
191const FOLD_QUOTE_DEPTH_THRESHOLD: u32 = 1;
197
198fn quote_depth(line: &str) -> u32 {
202 let mut depth = 0;
203 let mut rest = line;
204 while let Some(next) = rest.trim_start_matches(' ').strip_prefix('>') {
205 depth += 1;
206 rest = next;
207 }
208 depth
209}
210
211fn fold_quoted_lines(body: &str) -> String {
224 let lines: Vec<&str> = body.lines().collect();
225 let mut foldable: Vec<bool> = lines
226 .iter()
227 .map(|line| quote_depth(line) > FOLD_QUOTE_DEPTH_THRESHOLD)
228 .collect();
229
230 let mut i = 0;
231 while i < foldable.len() {
232 if foldable[i] || !lines[i].trim().is_empty() {
233 i += 1;
234 continue;
235 }
236 let gap_start = i;
237 while i < foldable.len() && !foldable[i] && lines[i].trim().is_empty() {
238 i += 1;
239 }
240 let bridged = gap_start > 0 && foldable[gap_start - 1] && i < foldable.len() && foldable[i];
241 if bridged {
242 for folded in &mut foldable[gap_start..i] {
243 *folded = true;
244 }
245 }
246 }
247
248 let mut out: Vec<String> = Vec::new();
249 let mut i = 0;
250 while i < lines.len() {
251 if !foldable[i] {
252 out.push(lines[i].to_string());
253 i += 1;
254 continue;
255 }
256 let start = i;
257 while i < lines.len() && foldable[i] {
258 i += 1;
259 }
260 let count = i - start;
261 let plural = if count == 1 { "" } else { "s" };
262 out.push(format!("*({count} quoted line{plural} omitted)*"));
263 }
264 out.join("\n")
265}
266
267#[cfg(test)]
268#[allow(clippy::unwrap_used, clippy::expect_used)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn render_markdown_renders_plain_text_message() {
274 let raw =
275 b"Subject: Hello\r\nFrom: Alice <a@example.com>\r\nTo: b@example.com\r\n\r\nHi there.";
276 let markdown = render_markdown(raw, false);
277 assert!(markdown.contains("# Hello"));
278 assert!(markdown.contains("- **From:** Alice <a@example.com>"));
279 assert!(markdown.contains("- **To:** b@example.com"));
280 assert!(markdown.contains("Hi there."));
281 }
282
283 #[test]
284 fn render_markdown_never_shows_bcc() {
285 let raw = b"Subject: Sent\r\nTo: b@example.com\r\nBcc: hidden@example.com\r\n\r\nHi.";
286 let markdown = render_markdown(raw, false);
287 assert!(!markdown.contains("hidden@example.com"), "{markdown}");
288 assert!(!markdown.contains("Draft-Id"), "{markdown}");
289 }
290
291 #[test]
292 fn render_draft_markdown_leads_with_the_draft_id_and_shows_bcc() {
293 let raw = b"Subject: Draft\r\nTo: b@example.com\r\nBcc: hidden@example.com\r\n\r\nHi.";
294 let markdown = render_draft_markdown(raw, false, "r1");
295 assert!(
296 markdown.starts_with("# Draft\n\n- **Draft-Id:** r1\n"),
297 "{markdown}"
298 );
299 assert!(
300 markdown.contains("- **Bcc:** hidden@example.com"),
301 "{markdown}"
302 );
303 assert!(markdown.contains("Hi."), "{markdown}");
304 }
305
306 #[test]
307 fn render_markdown_prefers_text_plain_in_multipart_alternative() {
308 let raw = b"Subject: Hi\r\nContent-Type: multipart/alternative; boundary=\"B\"\r\n\r\n\
309--B\r\nContent-Type: text/plain\r\n\r\nPlain body\r\n\
310--B\r\nContent-Type: text/html\r\n\r\n<p>HTML body</p>\r\n\
311--B--\r\n";
312 let markdown = render_markdown(raw, false);
313 assert!(markdown.contains("Plain body"));
314 assert!(!markdown.contains("HTML body"));
315 }
316
317 #[test]
318 fn render_markdown_falls_back_to_html_when_no_plain_text_part() {
319 let raw = b"Subject: Hi\r\nContent-Type: text/html\r\n\r\n<p>Only <b>HTML</b> here.</p>";
320 let markdown = render_markdown(raw, false);
321 assert!(markdown.contains("Only **HTML** here."));
322 }
323
324 #[test]
325 fn render_markdown_decodes_rfc2047_encoded_subject() {
326 let raw = b"Subject: =?utf-8?Q?You=20have=20a=20new=20message?=\r\nFrom: a@example.com\r\n\r\nBody.";
327 let markdown = render_markdown(raw, false);
328 assert!(markdown.contains("# You have a new message"));
329 }
330
331 #[test]
332 fn render_markdown_lists_attachment_filenames_without_embedding_contents() {
333 let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
334--B\r\nContent-Type: text/plain\r\n\r\nSee attached.\r\n\
335--B\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=\"report.pdf\"\r\n\r\ndata\r\n\
336--B--\r\n";
337 let markdown = render_markdown(raw, false);
338 assert!(markdown.contains("See attached."));
339 assert!(markdown.contains("## Attachments"));
340 assert!(markdown.contains("- report.pdf"));
341 assert!(!markdown.contains("data\n"));
342 }
343
344 #[test]
345 fn render_markdown_omits_attachments_section_when_none_present() {
346 let raw = b"Subject: Hi\r\nFrom: a@example.com\r\n\r\nJust text.";
347 let markdown = render_markdown(raw, false);
348 assert!(!markdown.contains("## Attachments"));
349 }
350
351 #[test]
352 fn render_markdown_degrades_gracefully_on_unparseable_input() {
353 let markdown = render_markdown(b"", false);
354 assert!(!markdown.is_empty());
355 }
356
357 #[test]
358 fn render_markdown_omits_absent_optional_headers() {
359 let raw = b"Subject: Hi\r\nFrom: a@example.com\r\n\r\nBody.";
360 let markdown = render_markdown(raw, false);
361 assert!(!markdown.contains("**Cc:**"));
362 assert!(!markdown.contains("**In-Reply-To:**"));
363 assert!(!markdown.contains("**References:**"));
364 }
365
366 #[test]
367 fn render_markdown_includes_in_reply_to_and_references_when_present() {
368 let raw = b"Subject: Re: Hi\r\nIn-Reply-To: <id1@example.com>\r\nReferences: <id1@example.com> <id2@example.com>\r\n\r\nBody.";
369 let markdown = render_markdown(raw, false);
370 assert!(markdown.contains("- **In-Reply-To:** id1@example.com"));
371 assert!(markdown.contains("- **References:** id1@example.com id2@example.com"));
372 }
373
374 #[test]
375 fn render_markdown_renders_a_bare_display_name_address_without_an_email() {
376 let raw = b"Subject: Hi\r\nFrom: Alice\r\n\r\nBody.";
379 let markdown = render_markdown(raw, false);
380 assert!(markdown.contains("- **From:** Alice"));
381 }
382
383 #[test]
384 fn format_addr_renders_empty_string_when_name_and_address_are_both_absent() {
385 assert_eq!(
389 format_addr(&Addr {
390 name: None,
391 address: None
392 }),
393 ""
394 );
395 }
396
397 fn synthetic_single_part_message(
408 type_: &str,
409 subtype: &str,
410 body: PartType<'static>,
411 ) -> Message<'static> {
412 let part = MessagePart {
413 headers: vec![Header {
414 name: HeaderName::ContentType,
415 value: HeaderValue::ContentType(ContentType {
416 c_type: type_.to_string().into(),
417 c_subtype: Some(subtype.to_string().into()),
418 attributes: None,
419 }),
420 offset_field: 0,
421 offset_start: 0,
422 offset_end: 0,
423 }],
424 is_encoding_problem: false,
425 body,
426 encoding: Encoding::None,
427 offset_header: 0,
428 offset_body: 0,
429 offset_end: 0,
430 };
431 Message {
432 html_body: vec![],
433 text_body: vec![0],
434 attachments: vec![],
435 parts: vec![part],
436 raw_message: Vec::new().into(),
437 }
438 }
439
440 #[test]
441 fn body_markdown_falls_back_past_a_declared_plain_text_part_with_no_actual_text_body() {
442 let message = synthetic_single_part_message(
446 "text",
447 "plain",
448 PartType::Binary(b"irrelevant".as_slice().into()),
449 );
450 assert_eq!(body_markdown(&message, false), "*(no body)*");
451 }
452
453 #[test]
454 fn body_markdown_falls_back_to_plain_text_body_for_a_non_plain_text_part() {
455 let message = synthetic_single_part_message("text", "csv", PartType::Text("a,b,c".into()));
459 assert_eq!(body_markdown(&message, false), "a,b,c");
460 }
461
462 #[test]
463 fn body_markdown_falls_back_to_no_body_placeholder_when_the_only_part_is_an_attachment() {
464 let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
465--B\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; filename=\"a.txt\"\r\n\r\nAttached text\r\n\
466--B--\r\n";
467 let raw = raw.to_vec();
468 let message = MessageParser::default().parse(&raw).unwrap();
469 assert_eq!(body_markdown(&message, false), "*(no body)*");
470 }
471
472 #[test]
473 fn quote_depth_counts_various_quote_marker_spacings() {
474 assert_eq!(quote_depth("no quote here"), 0);
475 assert_eq!(quote_depth(">>> text"), 3);
476 assert_eq!(quote_depth("> > > text"), 3);
477 assert_eq!(quote_depth(">> > text"), 3);
478 assert_eq!(quote_depth("> text"), 1);
479 }
480
481 #[test]
482 fn fold_quoted_lines_leaves_unquoted_text_unchanged() {
483 let body = "New reply.\n\nSecond paragraph.";
484 assert_eq!(fold_quoted_lines(body), body);
485 }
486
487 #[test]
488 fn fold_quoted_lines_leaves_a_single_quote_level_unchanged() {
489 let body = "New reply.\n\n> Quoted line one.\n> Quoted line two.";
490 assert_eq!(fold_quoted_lines(body), body);
491 }
492
493 #[test]
494 fn fold_quoted_lines_folds_a_nested_quote_block_into_a_marker() {
495 let body = "New reply.\n\n> Alice wrote:\n>> Original line one.\n>> Original line two.";
496 assert_eq!(
497 fold_quoted_lines(body),
498 "New reply.\n\n> Alice wrote:\n*(2 quoted lines omitted)*"
499 );
500 }
501
502 #[test]
503 fn fold_quoted_lines_folds_both_spaced_and_unspaced_nested_quote_markers() {
504 let body = ">> unspaced nested\n> > spaced nested";
505 assert_eq!(fold_quoted_lines(body), "*(2 quoted lines omitted)*");
506 }
507
508 #[test]
509 fn fold_quoted_lines_bridges_an_unprefixed_blank_line_inside_a_nested_quote_run() {
510 let body = ">> First nested paragraph.\n\n>> Second nested paragraph.";
511 assert_eq!(fold_quoted_lines(body), "*(3 quoted lines omitted)*");
512 }
513
514 #[test]
515 fn fold_quoted_lines_does_not_bridge_a_blank_line_outside_a_foldable_run() {
516 let body = "New reply.\n\n>> Nested quote.";
517 assert_eq!(
518 fold_quoted_lines(body),
519 "New reply.\n\n*(1 quoted line omitted)*"
520 );
521 }
522
523 #[test]
524 fn render_markdown_folds_nested_html_blockquotes_when_fold_quotes_is_true() {
525 let raw = b"Subject: Hi\r\nContent-Type: text/html\r\n\r\n\
526<p>New reply.</p><blockquote>Alice wrote:<blockquote>Bob's original message.</blockquote></blockquote>";
527
528 let folded = render_markdown(raw, true);
529 assert!(folded.contains("New reply."));
530 assert!(folded.contains("omitted"));
531 assert!(!folded.contains("Bob's original message."));
532
533 let unfolded = render_markdown(raw, false);
534 assert!(unfolded.contains("Bob's original message."));
535 assert!(!unfolded.contains("omitted"));
536 }
537}