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    render(raw, fold_quotes, None)
37}
38
39/// Renders a draft's `raw` message as [`render_markdown`] does, with two
40/// additions: a leading `Draft-Id` bullet, the id that stays the same across
41/// saves, and the `Bcc` header.
42///
43/// `Bcc` is draft-only on purpose. A draft's stored message keeps it and
44/// its author needs to see it, but Gmail keeps `Bcc` on Sent mail too, and
45/// `gmail read`/`gmail render` output is the kind of thing people paste
46/// elsewhere, where blind-copied recipients must stay hidden.
47pub(crate) fn render_draft_markdown(raw: &[u8], fold_quotes: bool, draft_id: &str) -> String {
48    render(raw, fold_quotes, Some(draft_id))
49}
50
51/// The body of [`render_markdown`] and [`render_draft_markdown`]; a
52/// `draft_id` switches on the draft-only header bullets.
53fn 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
106/// Appends a `- **label:** value` bullet when `value` is present. The
107/// header block is a bullet list rather than bare lines so every field
108/// reliably lands on its own line under CommonMark, where consecutive plain
109/// text lines are soft-wrapped into a single paragraph.
110fn 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
116/// Formats an address header's value as a comma-separated `"Name
117/// <email>"` list, or `None` when the header is absent/empty.
118fn 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
133/// Formats an `In-Reply-To`/`References`-shaped header value: a single
134/// message-id, or a whitespace-separated list of them.
135fn 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
145/// Prefers the message's genuine `text/plain` body; falls back to its
146/// `text/html` body converted to Markdown via `htmd` (`mail-parser` resolves
147/// MIME structure but does no HTML-to-Markdown rendering itself). A
148/// conversion error — real HTML in practice never triggers one, since
149/// `htmd`'s underlying `html5ever` parser tolerates malformed markup like a
150/// browser would — falls back to the raw HTML text rather than dropping the
151/// body entirely.
152///
153/// Deliberately checks the resolved part's actual content type rather than
154/// just calling `body_text(0)` first: for an HTML-only message, `mail-parser`
155/// still populates `text_body` by pointing it at that same HTML part and
156/// returning its own crude tag-stripped rendering from `body_text` — that
157/// would silently take priority over `htmd`'s much better Markdown
158/// conversion for the overwhelmingly common HTML-only-marketing-mail case
159/// (see #1513) unless genuineness is checked first.
160fn 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
179/// Applies [`fold_quoted_lines`] to a resolved body when requested. Shared
180/// by every `body_markdown` return branch (genuine plain-text, `htmd`-
181/// converted HTML, and the final plain-text fallback) so folding behaves
182/// identically regardless of which branch produced the body.
183fn finish_body(body: String, fold_quotes: bool) -> String {
184    if fold_quotes {
185        fold_quoted_lines(&body)
186    } else {
187        body
188    }
189}
190
191/// Depth beyond which a quote block is folded (#1514). The immediately-
192/// preceding reply's quote (depth 1) stays visible for context; anything
193/// nested deeper is replaced with a one-line marker. A fixed constant
194/// rather than a CLI-configurable number — nothing yet motivates per-call
195/// tuning, and the originating issue only asked for "some threshold".
196const FOLD_QUOTE_DEPTH_THRESHOLD: u32 = 1;
197
198/// Counts a line's leading `>` quote markers, tolerating any amount of
199/// whitespace between them — so `>>>`, `> > >`, and mixed spacing like
200/// `>>  >` all count as depth 3. A line with no leading `>` has depth 0.
201fn 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
211/// Folds `>`-quoted lines nested deeper than [`FOLD_QUOTE_DEPTH_THRESHOLD`]
212/// into a one-line `*(N quoted lines omitted)*` marker, leaving shallower
213/// quote levels and unquoted content untouched. Operates purely on
214/// `>`-prefixed lines, so it applies identically to native plain-text
215/// quoting and to `htmd`'s `<blockquote>`-to-`>`-line conversion (both
216/// converge on the same per-line `>`-depth shape by the time a body reaches
217/// this function — see the module doc comment).
218///
219/// A run of blank lines sitting between two foldable quote blocks is
220/// bridged into the fold rather than left as a visible gap, so a lone blank
221/// quote-separator line doesn't fragment one nested quote block into
222/// several tiny markers.
223fn 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        // mail-parser leniently parses a `From:` header with no `<email>`
377        // as an address with a name but no address.
378        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        // Not reachable through mail-parser's own output (it drops
386        // malformed address entries rather than yielding one with neither
387        // field set) — exercised directly against the pure helper instead.
388        assert_eq!(
389            format_addr(&Addr {
390                name: None,
391                address: None
392            }),
393            ""
394        );
395    }
396
397    /// Builds a single-part synthetic `Message` whose one part declares
398    /// `type_`/`subtype` as its Content-Type header and carries `body` as
399    /// its parsed content, with `text_body` (and no `html_body`) pointing
400    /// at it. `mail-parser`'s own parser always keeps a part's declared
401    /// Content-Type and its parsed [`PartType`] in sync (a `text/plain`
402    /// header is always parsed as `PartType::Text`, never
403    /// `PartType::Binary`), so the mismatches below aren't reachable through
404    /// real parsing — this constructs them directly, the same workaround
405    /// `format_addr_renders_empty_string_when_name_and_address_are_both_absent`
406    /// above uses for a state `mail-parser` itself never produces.
407    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        // text_part(0) reports text/plain, but the part's actual body is
443        // PartType::Binary — a state real parsing never produces (see
444        // `synthetic_single_part_message`'s doc comment).
445        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        // text_part(0) reports text/csv (not text/plain), so the genuine-
456        // plain-text branch is skipped; there's no html_body entry, so this
457        // falls through to the final body_text(0) fallback.
458        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}