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 let Some(message) = MessageParser::default().parse(raw) else {
37 return "*(unable to parse this message)*\n".to_string();
38 };
39
40 let mut out = String::new();
41 let _ = writeln!(out, "# {}\n", message.subject().unwrap_or("(no subject)"));
42
43 write_header(&mut out, "From", format_address(message.from()));
44 write_header(&mut out, "To", format_address(message.to()));
45 write_header(&mut out, "Cc", format_address(message.cc()));
46 write_header(
47 &mut out,
48 "Date",
49 message.date().map(mail_parser::DateTime::to_rfc822),
50 );
51 write_header(
52 &mut out,
53 "Message-Id",
54 message.message_id().map(str::to_string),
55 );
56 write_header(
57 &mut out,
58 "In-Reply-To",
59 format_header_value(message.in_reply_to()),
60 );
61 write_header(
62 &mut out,
63 "References",
64 format_header_value(message.references()),
65 );
66
67 out.push('\n');
68 out.push_str(body_markdown(&message, fold_quotes).trim_end());
69 out.push('\n');
70
71 let attachments = extract_attachments(raw);
72 if !attachments.is_empty() {
73 out.push_str("\n## Attachments\n\n");
74 for attachment in &attachments {
75 let _ = writeln!(out, "- {}", attachment.filename);
76 }
77 }
78
79 out
80}
81
82fn write_header(out: &mut String, label: &str, value: Option<String>) {
87 if let Some(value) = value {
88 let _ = writeln!(out, "- **{label}:** {value}");
89 }
90}
91
92fn format_address(address: Option<&Address>) -> Option<String> {
95 let address = address?;
96 let formatted: Vec<String> = address.iter().map(format_addr).collect();
97 (!formatted.is_empty()).then(|| formatted.join(", "))
98}
99
100fn format_addr(addr: &Addr) -> String {
101 match (&addr.name, &addr.address) {
102 (Some(name), Some(email)) => format!("{name} <{email}>"),
103 (Some(name), None) => name.to_string(),
104 (None, Some(email)) => email.to_string(),
105 (None, None) => String::new(),
106 }
107}
108
109fn format_header_value(value: &HeaderValue) -> Option<String> {
112 match value {
113 HeaderValue::Text(text) => Some(text.to_string()),
114 HeaderValue::TextList(list) if !list.is_empty() => {
115 Some(list.iter().map(AsRef::as_ref).collect::<Vec<_>>().join(" "))
116 }
117 _ => None,
118 }
119}
120
121fn body_markdown(message: &Message, fold_quotes: bool) -> String {
137 if message
138 .text_part(0)
139 .is_some_and(|part| part.is_content_type("text", "plain"))
140 {
141 if let Some(text) = message.body_text(0) {
142 return finish_body(text.into_owned(), fold_quotes);
143 }
144 }
145 if let Some(html) = message.body_html(0) {
146 let converted = htmd::convert(&html).unwrap_or_else(|_| html.into_owned());
147 return finish_body(converted, fold_quotes);
148 }
149 if let Some(text) = message.body_text(0) {
150 return finish_body(text.into_owned(), fold_quotes);
151 }
152 "*(no body)*".to_string()
153}
154
155fn finish_body(body: String, fold_quotes: bool) -> String {
160 if fold_quotes {
161 fold_quoted_lines(&body)
162 } else {
163 body
164 }
165}
166
167const FOLD_QUOTE_DEPTH_THRESHOLD: u32 = 1;
173
174fn quote_depth(line: &str) -> u32 {
178 let mut depth = 0;
179 let mut rest = line;
180 while let Some(next) = rest.trim_start_matches(' ').strip_prefix('>') {
181 depth += 1;
182 rest = next;
183 }
184 depth
185}
186
187fn fold_quoted_lines(body: &str) -> String {
200 let lines: Vec<&str> = body.lines().collect();
201 let mut foldable: Vec<bool> = lines
202 .iter()
203 .map(|line| quote_depth(line) > FOLD_QUOTE_DEPTH_THRESHOLD)
204 .collect();
205
206 let mut i = 0;
207 while i < foldable.len() {
208 if foldable[i] || !lines[i].trim().is_empty() {
209 i += 1;
210 continue;
211 }
212 let gap_start = i;
213 while i < foldable.len() && !foldable[i] && lines[i].trim().is_empty() {
214 i += 1;
215 }
216 let bridged = gap_start > 0 && foldable[gap_start - 1] && i < foldable.len() && foldable[i];
217 if bridged {
218 for folded in &mut foldable[gap_start..i] {
219 *folded = true;
220 }
221 }
222 }
223
224 let mut out: Vec<String> = Vec::new();
225 let mut i = 0;
226 while i < lines.len() {
227 if !foldable[i] {
228 out.push(lines[i].to_string());
229 i += 1;
230 continue;
231 }
232 let start = i;
233 while i < lines.len() && foldable[i] {
234 i += 1;
235 }
236 let count = i - start;
237 let plural = if count == 1 { "" } else { "s" };
238 out.push(format!("*({count} quoted line{plural} omitted)*"));
239 }
240 out.join("\n")
241}
242
243#[cfg(test)]
244#[allow(clippy::unwrap_used, clippy::expect_used)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn render_markdown_renders_plain_text_message() {
250 let raw =
251 b"Subject: Hello\r\nFrom: Alice <a@example.com>\r\nTo: b@example.com\r\n\r\nHi there.";
252 let markdown = render_markdown(raw, false);
253 assert!(markdown.contains("# Hello"));
254 assert!(markdown.contains("- **From:** Alice <a@example.com>"));
255 assert!(markdown.contains("- **To:** b@example.com"));
256 assert!(markdown.contains("Hi there."));
257 }
258
259 #[test]
260 fn render_markdown_prefers_text_plain_in_multipart_alternative() {
261 let raw = b"Subject: Hi\r\nContent-Type: multipart/alternative; boundary=\"B\"\r\n\r\n\
262--B\r\nContent-Type: text/plain\r\n\r\nPlain body\r\n\
263--B\r\nContent-Type: text/html\r\n\r\n<p>HTML body</p>\r\n\
264--B--\r\n";
265 let markdown = render_markdown(raw, false);
266 assert!(markdown.contains("Plain body"));
267 assert!(!markdown.contains("HTML body"));
268 }
269
270 #[test]
271 fn render_markdown_falls_back_to_html_when_no_plain_text_part() {
272 let raw = b"Subject: Hi\r\nContent-Type: text/html\r\n\r\n<p>Only <b>HTML</b> here.</p>";
273 let markdown = render_markdown(raw, false);
274 assert!(markdown.contains("Only **HTML** here."));
275 }
276
277 #[test]
278 fn render_markdown_decodes_rfc2047_encoded_subject() {
279 let raw = b"Subject: =?utf-8?Q?You=20have=20a=20new=20message?=\r\nFrom: a@example.com\r\n\r\nBody.";
280 let markdown = render_markdown(raw, false);
281 assert!(markdown.contains("# You have a new message"));
282 }
283
284 #[test]
285 fn render_markdown_lists_attachment_filenames_without_embedding_contents() {
286 let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
287--B\r\nContent-Type: text/plain\r\n\r\nSee attached.\r\n\
288--B\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=\"report.pdf\"\r\n\r\ndata\r\n\
289--B--\r\n";
290 let markdown = render_markdown(raw, false);
291 assert!(markdown.contains("See attached."));
292 assert!(markdown.contains("## Attachments"));
293 assert!(markdown.contains("- report.pdf"));
294 assert!(!markdown.contains("data\n"));
295 }
296
297 #[test]
298 fn render_markdown_omits_attachments_section_when_none_present() {
299 let raw = b"Subject: Hi\r\nFrom: a@example.com\r\n\r\nJust text.";
300 let markdown = render_markdown(raw, false);
301 assert!(!markdown.contains("## Attachments"));
302 }
303
304 #[test]
305 fn render_markdown_degrades_gracefully_on_unparseable_input() {
306 let markdown = render_markdown(b"", false);
307 assert!(!markdown.is_empty());
308 }
309
310 #[test]
311 fn render_markdown_omits_absent_optional_headers() {
312 let raw = b"Subject: Hi\r\nFrom: a@example.com\r\n\r\nBody.";
313 let markdown = render_markdown(raw, false);
314 assert!(!markdown.contains("**Cc:**"));
315 assert!(!markdown.contains("**In-Reply-To:**"));
316 assert!(!markdown.contains("**References:**"));
317 }
318
319 #[test]
320 fn render_markdown_includes_in_reply_to_and_references_when_present() {
321 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.";
322 let markdown = render_markdown(raw, false);
323 assert!(markdown.contains("- **In-Reply-To:** id1@example.com"));
324 assert!(markdown.contains("- **References:** id1@example.com id2@example.com"));
325 }
326
327 #[test]
328 fn render_markdown_renders_a_bare_display_name_address_without_an_email() {
329 let raw = b"Subject: Hi\r\nFrom: Alice\r\n\r\nBody.";
332 let markdown = render_markdown(raw, false);
333 assert!(markdown.contains("- **From:** Alice"));
334 }
335
336 #[test]
337 fn format_addr_renders_empty_string_when_name_and_address_are_both_absent() {
338 assert_eq!(
342 format_addr(&Addr {
343 name: None,
344 address: None
345 }),
346 ""
347 );
348 }
349
350 fn synthetic_single_part_message(
361 type_: &str,
362 subtype: &str,
363 body: PartType<'static>,
364 ) -> Message<'static> {
365 let part = MessagePart {
366 headers: vec![Header {
367 name: HeaderName::ContentType,
368 value: HeaderValue::ContentType(ContentType {
369 c_type: type_.to_string().into(),
370 c_subtype: Some(subtype.to_string().into()),
371 attributes: None,
372 }),
373 offset_field: 0,
374 offset_start: 0,
375 offset_end: 0,
376 }],
377 is_encoding_problem: false,
378 body,
379 encoding: Encoding::None,
380 offset_header: 0,
381 offset_body: 0,
382 offset_end: 0,
383 };
384 Message {
385 html_body: vec![],
386 text_body: vec![0],
387 attachments: vec![],
388 parts: vec![part],
389 raw_message: Vec::new().into(),
390 }
391 }
392
393 #[test]
394 fn body_markdown_falls_back_past_a_declared_plain_text_part_with_no_actual_text_body() {
395 let message = synthetic_single_part_message(
399 "text",
400 "plain",
401 PartType::Binary(b"irrelevant".as_slice().into()),
402 );
403 assert_eq!(body_markdown(&message, false), "*(no body)*");
404 }
405
406 #[test]
407 fn body_markdown_falls_back_to_plain_text_body_for_a_non_plain_text_part() {
408 let message = synthetic_single_part_message("text", "csv", PartType::Text("a,b,c".into()));
412 assert_eq!(body_markdown(&message, false), "a,b,c");
413 }
414
415 #[test]
416 fn body_markdown_falls_back_to_no_body_placeholder_when_the_only_part_is_an_attachment() {
417 let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
418--B\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; filename=\"a.txt\"\r\n\r\nAttached text\r\n\
419--B--\r\n";
420 let raw = raw.to_vec();
421 let message = MessageParser::default().parse(&raw).unwrap();
422 assert_eq!(body_markdown(&message, false), "*(no body)*");
423 }
424
425 #[test]
426 fn quote_depth_counts_various_quote_marker_spacings() {
427 assert_eq!(quote_depth("no quote here"), 0);
428 assert_eq!(quote_depth(">>> text"), 3);
429 assert_eq!(quote_depth("> > > text"), 3);
430 assert_eq!(quote_depth(">> > text"), 3);
431 assert_eq!(quote_depth("> text"), 1);
432 }
433
434 #[test]
435 fn fold_quoted_lines_leaves_unquoted_text_unchanged() {
436 let body = "New reply.\n\nSecond paragraph.";
437 assert_eq!(fold_quoted_lines(body), body);
438 }
439
440 #[test]
441 fn fold_quoted_lines_leaves_a_single_quote_level_unchanged() {
442 let body = "New reply.\n\n> Quoted line one.\n> Quoted line two.";
443 assert_eq!(fold_quoted_lines(body), body);
444 }
445
446 #[test]
447 fn fold_quoted_lines_folds_a_nested_quote_block_into_a_marker() {
448 let body = "New reply.\n\n> Alice wrote:\n>> Original line one.\n>> Original line two.";
449 assert_eq!(
450 fold_quoted_lines(body),
451 "New reply.\n\n> Alice wrote:\n*(2 quoted lines omitted)*"
452 );
453 }
454
455 #[test]
456 fn fold_quoted_lines_folds_both_spaced_and_unspaced_nested_quote_markers() {
457 let body = ">> unspaced nested\n> > spaced nested";
458 assert_eq!(fold_quoted_lines(body), "*(2 quoted lines omitted)*");
459 }
460
461 #[test]
462 fn fold_quoted_lines_bridges_an_unprefixed_blank_line_inside_a_nested_quote_run() {
463 let body = ">> First nested paragraph.\n\n>> Second nested paragraph.";
464 assert_eq!(fold_quoted_lines(body), "*(3 quoted lines omitted)*");
465 }
466
467 #[test]
468 fn fold_quoted_lines_does_not_bridge_a_blank_line_outside_a_foldable_run() {
469 let body = "New reply.\n\n>> Nested quote.";
470 assert_eq!(
471 fold_quoted_lines(body),
472 "New reply.\n\n*(1 quoted line omitted)*"
473 );
474 }
475
476 #[test]
477 fn render_markdown_folds_nested_html_blockquotes_when_fold_quotes_is_true() {
478 let raw = b"Subject: Hi\r\nContent-Type: text/html\r\n\r\n\
479<p>New reply.</p><blockquote>Alice wrote:<blockquote>Bob's original message.</blockquote></blockquote>";
480
481 let folded = render_markdown(raw, true);
482 assert!(folded.contains("New reply."));
483 assert!(folded.contains("omitted"));
484 assert!(!folded.contains("Bob's original message."));
485
486 let unfolded = render_markdown(raw, false);
487 assert!(unfolded.contains("Bob's original message."));
488 assert!(!unfolded.contains("omitted"));
489 }
490}