Skip to main content

omni_dev/gmail/
render.rs

1//! Renders a raw MIME message (an archived `.eml`, or any RFC 5322 message)
2//! as human-readable Markdown, backing `gmail render` and `gmail read -o
3//! markdown` (#1513).
4//!
5//! A sibling of [`crate::gmail::attachments`], not an extension of
6//! [`crate::gmail::raw_message`]'s line-oriented scanner: producing readable
7//! text needs the same real MIME/multipart parsing `attachments.rs` already
8//! pulls in (`mail-parser`), plus RFC 2047 encoded-word decoding of header
9//! values — which that scanner's own doc comment explicitly says it does not
10//! attempt.
11
12use 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
20/// Renders `raw` as Markdown: a header block (Subject/From/To/Cc/Date/
21/// Message-Id/In-Reply-To/References, RFC 2047-decoded courtesy of
22/// `mail-parser`), the message body (preferring `text/plain`, falling back
23/// to `text/html` converted via `htmd`), and a bullet list of attachment
24/// filenames (listed, never embedded — this is a readable rendering, not an
25/// export).
26///
27/// `fold_quotes` controls whether deeply-nested `>`-quoted reply history in
28/// the body is collapsed into one-line markers (#1514) — see
29/// [`fold_quoted_lines`].
30///
31/// Never fails: an unparseable message degrades to a short placeholder
32/// rather than erroring, the same posture
33/// [`extract_attachments`] takes so a batch caller like `gmail render`
34/// never has one bad file abort the whole run.
35pub(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
82/// Appends a `- **label:** value` bullet when `value` is present. The
83/// header block is a bullet list rather than bare lines so every field
84/// reliably lands on its own line under CommonMark, where consecutive plain
85/// text lines are soft-wrapped into a single paragraph.
86fn 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
92/// Formats an address header's value as a comma-separated `"Name
93/// <email>"` list, or `None` when the header is absent/empty.
94fn 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
109/// Formats an `In-Reply-To`/`References`-shaped header value: a single
110/// message-id, or a whitespace-separated list of them.
111fn 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
121/// Prefers the message's genuine `text/plain` body; falls back to its
122/// `text/html` body converted to Markdown via `htmd` (`mail-parser` resolves
123/// MIME structure but does no HTML-to-Markdown rendering itself). A
124/// conversion error — real HTML in practice never triggers one, since
125/// `htmd`'s underlying `html5ever` parser tolerates malformed markup like a
126/// browser would — falls back to the raw HTML text rather than dropping the
127/// body entirely.
128///
129/// Deliberately checks the resolved part's actual content type rather than
130/// just calling `body_text(0)` first: for an HTML-only message, `mail-parser`
131/// still populates `text_body` by pointing it at that same HTML part and
132/// returning its own crude tag-stripped rendering from `body_text` — that
133/// would silently take priority over `htmd`'s much better Markdown
134/// conversion for the overwhelmingly common HTML-only-marketing-mail case
135/// (see #1513) unless genuineness is checked first.
136fn 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
155/// Applies [`fold_quoted_lines`] to a resolved body when requested. Shared
156/// by every `body_markdown` return branch (genuine plain-text, `htmd`-
157/// converted HTML, and the final plain-text fallback) so folding behaves
158/// identically regardless of which branch produced the body.
159fn finish_body(body: String, fold_quotes: bool) -> String {
160    if fold_quotes {
161        fold_quoted_lines(&body)
162    } else {
163        body
164    }
165}
166
167/// Depth beyond which a quote block is folded (#1514). The immediately-
168/// preceding reply's quote (depth 1) stays visible for context; anything
169/// nested deeper is replaced with a one-line marker. A fixed constant
170/// rather than a CLI-configurable number — nothing yet motivates per-call
171/// tuning, and the originating issue only asked for "some threshold".
172const FOLD_QUOTE_DEPTH_THRESHOLD: u32 = 1;
173
174/// Counts a line's leading `>` quote markers, tolerating any amount of
175/// whitespace between them — so `>>>`, `> > >`, and mixed spacing like
176/// `>>  >` all count as depth 3. A line with no leading `>` has depth 0.
177fn 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
187/// Folds `>`-quoted lines nested deeper than [`FOLD_QUOTE_DEPTH_THRESHOLD`]
188/// into a one-line `*(N quoted lines omitted)*` marker, leaving shallower
189/// quote levels and unquoted content untouched. Operates purely on
190/// `>`-prefixed lines, so it applies identically to native plain-text
191/// quoting and to `htmd`'s `<blockquote>`-to-`>`-line conversion (both
192/// converge on the same per-line `>`-depth shape by the time a body reaches
193/// this function — see the module doc comment).
194///
195/// A run of blank lines sitting between two foldable quote blocks is
196/// bridged into the fold rather than left as a visible gap, so a lone blank
197/// quote-separator line doesn't fragment one nested quote block into
198/// several tiny markers.
199fn 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        // mail-parser leniently parses a `From:` header with no `<email>`
330        // as an address with a name but no address.
331        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        // Not reachable through mail-parser's own output (it drops
339        // malformed address entries rather than yielding one with neither
340        // field set) — exercised directly against the pure helper instead.
341        assert_eq!(
342            format_addr(&Addr {
343                name: None,
344                address: None
345            }),
346            ""
347        );
348    }
349
350    /// Builds a single-part synthetic `Message` whose one part declares
351    /// `type_`/`subtype` as its Content-Type header and carries `body` as
352    /// its parsed content, with `text_body` (and no `html_body`) pointing
353    /// at it. `mail-parser`'s own parser always keeps a part's declared
354    /// Content-Type and its parsed [`PartType`] in sync (a `text/plain`
355    /// header is always parsed as `PartType::Text`, never
356    /// `PartType::Binary`), so the mismatches below aren't reachable through
357    /// real parsing — this constructs them directly, the same workaround
358    /// `format_addr_renders_empty_string_when_name_and_address_are_both_absent`
359    /// above uses for a state `mail-parser` itself never produces.
360    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        // text_part(0) reports text/plain, but the part's actual body is
396        // PartType::Binary — a state real parsing never produces (see
397        // `synthetic_single_part_message`'s doc comment).
398        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        // text_part(0) reports text/csv (not text/plain), so the genuine-
409        // plain-text branch is skipped; there's no html_body entry, so this
410        // falls through to the final body_text(0) fallback.
411        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}