Skip to main content

omni_dev/gmail/
raw_message.rs

1//! Decoding and light header extraction for `format=raw` Gmail messages.
2//!
3//! `format=raw` returns the complete RFC 2822 message, base64url-encoded.
4//! [`decode_raw_message`] (promoted here from `src/cli/gmail/read.rs`, fixed
5//! in commit `d7b6c0a0`) turns that back into real bytes; [`extract_headers`]
6//! pulls a handful of headers out of those bytes without a MIME parser or a
7//! second `format=metadata` network round-trip — `gmail sync`
8//! (`src/cli/gmail/sync/engine.rs`) needs both to write a byte-exact `.eml`
9//! and populate its manifest from data already in hand.
10
11use anyhow::{Context, Result};
12use base64::Engine as _;
13use percent_encoding::percent_decode_str;
14use std::collections::HashMap;
15
16use crate::gmail::types::Message;
17
18/// Base64url-decodes [`Message::raw`] into the literal RFC 2822 bytes Gmail
19/// returned — a genuine byte-exact copy, not a preview.
20///
21/// Gmail's own encoder omits padding, but this decodes leniently either way
22/// by stripping any trailing `=` before decoding unpadded.
23pub(crate) fn decode_raw_message(message: &Message) -> Result<Vec<u8>> {
24    let raw = message
25        .raw
26        .as_deref()
27        .ok_or_else(|| anyhow::anyhow!("Gmail's response for `--detail raw` had no `raw` field"))?;
28    base64::engine::general_purpose::URL_SAFE_NO_PAD
29        .decode(raw.trim_end_matches('='))
30        .context("Failed to base64url-decode the raw RFC 2822 message")
31}
32
33/// Extracts the first occurrence of each requested header (case-insensitive)
34/// from an RFC 5322 message's header block.
35///
36/// Not a MIME parser: this only scans the header block (stopping at the
37/// first blank line, the RFC 5322 header/body boundary), honours
38/// folded/continuation lines (a line starting with a space or tab continues
39/// the previous header's value), and does not decode RFC 2047 encoded-words
40/// (`=?UTF-8?B?...?=`) — non-ASCII header values are returned as their raw
41/// wire encoding. Missing/malformed `names` entries are simply absent from
42/// the result rather than erroring, since header presence varies by message.
43pub(crate) fn extract_headers(raw: &[u8], names: &[&str]) -> HashMap<String, String> {
44    let text = String::from_utf8_lossy(raw);
45    let mut result = HashMap::new();
46
47    let mut current: Option<(String, String)> = None;
48    let flush = |current: Option<(String, String)>, result: &mut HashMap<String, String>| {
49        if let Some((name, value)) = current {
50            let matched = names.iter().find(|n| n.eq_ignore_ascii_case(&name));
51            if let Some(&matched_name) = matched {
52                result.entry(matched_name.to_string()).or_insert(value);
53            }
54        }
55    };
56
57    for line in text.split(['\n']) {
58        let line = line.strip_suffix('\r').unwrap_or(line);
59        if line.is_empty() {
60            break; // end of header block
61        }
62        if line.starts_with(' ') || line.starts_with('\t') {
63            // Folded continuation of the previous header's value.
64            if let Some((_, value)) = current.as_mut() {
65                value.push(' ');
66                value.push_str(line.trim());
67            }
68            continue;
69        }
70        if let Some((name, value)) = line.split_once(':') {
71            flush(current.take(), &mut result);
72            current = Some((name.trim().to_string(), value.trim().to_string()));
73        }
74    }
75    flush(current, &mut result);
76
77    result
78}
79
80/// What [`extract_attachment_filenames`] found in a message.
81#[derive(Debug, Clone, PartialEq, Eq, Default)]
82pub(crate) struct AttachmentSummary {
83    /// Number of `Content-Disposition: attachment` parts found, regardless
84    /// of whether a filename parameter was present/parseable.
85    pub(crate) count: usize,
86    /// Filenames actually extracted — a subset of `count`, since not every
87    /// attachment part names itself.
88    pub(crate) filenames: Vec<String>,
89}
90
91/// Scans a raw RFC 5322/MIME message for `Content-Disposition: attachment`
92/// parts and reports how many were found and whichever filenames could be
93/// parsed from them.
94///
95/// Not a MIME parser: a line-oriented scan for the `Content-Disposition`
96/// header wherever it appears — top-level or within any MIME part, at any
97/// nesting depth, since boundary markers are never parsed — honouring the
98/// same header-folding rule as [`extract_headers`]. Unlike `extract_headers`,
99/// this scans the *entire* raw content rather than stopping at the first
100/// blank line, since `Content-Disposition` normally lives inside a MIME
101/// part's own header block, past the top-level headers `extract_headers`
102/// reads. `inline` (not `attachment`) dispositions are not counted. RFC 2231
103/// continuation parameters (`filename*0*=`/`filename*1*=`, for filenames
104/// long enough to need splitting across several parameters) are not
105/// handled — rare enough that supporting them would push this past "cheap
106/// heuristic" into a real MIME parser.
107pub(crate) fn extract_attachment_filenames(raw: &[u8]) -> AttachmentSummary {
108    let text = String::from_utf8_lossy(raw);
109    let mut summary = AttachmentSummary::default();
110
111    let mut current: Option<String> = None;
112    let flush = |current: Option<String>, summary: &mut AttachmentSummary| {
113        let Some(value) = current else { return };
114        if !value
115            .trim_start()
116            .to_ascii_lowercase()
117            .starts_with("attachment")
118        {
119            return;
120        }
121        summary.count += 1;
122        if let Some(name) = parse_filename_param(&value) {
123            summary.filenames.push(name);
124        }
125    };
126
127    for line in text.split('\n') {
128        let line = line.strip_suffix('\r').unwrap_or(line);
129        if line.starts_with(' ') || line.starts_with('\t') {
130            // Folded continuation of the previous header's value.
131            if let Some(value) = current.as_mut() {
132                value.push(' ');
133                value.push_str(line.trim());
134            }
135            continue;
136        }
137        // A new logical header (or a blank/boundary line) ends whatever
138        // Content-Disposition header was being accumulated.
139        flush(current.take(), &mut summary);
140        if let Some((name, value)) = line.split_once(':') {
141            if name.trim().eq_ignore_ascii_case("Content-Disposition") {
142                current = Some(value.trim().to_string());
143            }
144        }
145    }
146    flush(current, &mut summary);
147
148    summary
149}
150
151/// Extracts a `filename=`/`filename*=` parameter from a `Content-Disposition`
152/// header's value, preferring the RFC 2231 `filename*=charset'lang'percent-
153/// encoded` form when both it and a plain `filename=` are present (senders
154/// often include both, the plain one only for compatibility).
155fn parse_filename_param(value: &str) -> Option<String> {
156    let mut plain = None;
157    let mut encoded = None;
158    for part in value.split(';') {
159        let part = part.trim();
160        if let Some(rest) = part.strip_prefix("filename*=") {
161            encoded = Some(rest.to_string());
162        } else if let Some(rest) = part.strip_prefix("filename=") {
163            plain = Some(rest.trim_matches('"').to_string());
164        }
165    }
166    if let Some(rest) = encoded {
167        // charset'lang'percent-encoded-value — only the last field matters.
168        let percent_encoded = rest.rsplit('\'').next().unwrap_or(&rest);
169        return Some(
170            percent_decode_str(percent_encoded)
171                .decode_utf8_lossy()
172                .into_owned(),
173        );
174    }
175    plain
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used, clippy::expect_used)]
180mod tests {
181    use super::*;
182
183    // ── decode_raw_message ───────────────────────────────────────────
184
185    #[test]
186    fn decode_raw_message_decodes_unpadded_base64url() {
187        let source = "From: a@example.com\r\nSubject: Hi\r\n\r\nBody text.";
188        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(source);
189        let message = Message {
190            id: "m1".to_string(),
191            raw: Some(encoded),
192            ..Default::default()
193        };
194        let decoded = decode_raw_message(&message).unwrap();
195        assert_eq!(decoded, source.as_bytes());
196    }
197
198    #[test]
199    fn decode_raw_message_tolerates_padded_base64url() {
200        let source = "From: a@example.com\r\n\r\nBody.";
201        // `URL_SAFE` (padded) rather than `URL_SAFE_NO_PAD`: real-world
202        // encoders aren't guaranteed to omit padding, so decoding must not
203        // assume Gmail's own convention is the only valid input.
204        let encoded = base64::engine::general_purpose::URL_SAFE.encode(source);
205        let message = Message {
206            id: "m1".to_string(),
207            raw: Some(encoded),
208            ..Default::default()
209        };
210        let decoded = decode_raw_message(&message).unwrap();
211        assert_eq!(decoded, source.as_bytes());
212    }
213
214    #[test]
215    fn decode_raw_message_errors_when_raw_field_absent() {
216        let message = Message {
217            id: "m1".to_string(),
218            ..Default::default()
219        };
220        let err = decode_raw_message(&message).unwrap_err();
221        assert!(err.to_string().contains("no `raw` field"));
222    }
223
224    #[test]
225    fn decode_raw_message_errors_on_malformed_base64() {
226        let message = Message {
227            id: "m1".to_string(),
228            raw: Some("not valid base64url!!!".to_string()),
229            ..Default::default()
230        };
231        let err = decode_raw_message(&message).unwrap_err();
232        assert!(err.to_string().contains("base64url-decode"));
233    }
234
235    // ── extract_headers ────────────────────────────────────────────────
236
237    #[test]
238    fn extract_headers_matches_requested_names_case_insensitively() {
239        let raw = b"From: a@example.com\r\nSUBJECT: Hello\r\nMessage-Id: <1@x>\r\n\r\nBody";
240        let headers = extract_headers(raw, &["From", "Subject", "Message-Id"]);
241        assert_eq!(
242            headers.get("From").map(String::as_str),
243            Some("a@example.com")
244        );
245        assert_eq!(headers.get("Subject").map(String::as_str), Some("Hello"));
246        assert_eq!(headers.get("Message-Id").map(String::as_str), Some("<1@x>"));
247    }
248
249    #[test]
250    fn extract_headers_stops_at_first_blank_line() {
251        let raw = b"From: a@example.com\r\n\r\nSubject: not-a-header-its-body";
252        let headers = extract_headers(raw, &["Subject"]);
253        assert!(!headers.contains_key("Subject"));
254    }
255
256    #[test]
257    fn extract_headers_unfolds_continuation_lines() {
258        let raw = b"Subject: Hello\r\n world\r\n\r\nBody";
259        let headers = extract_headers(raw, &["Subject"]);
260        assert_eq!(
261            headers.get("Subject").map(String::as_str),
262            Some("Hello world")
263        );
264    }
265
266    #[test]
267    fn extract_headers_ignores_unrequested_headers() {
268        let raw = b"From: a@example.com\r\nX-Other: whatever\r\n\r\nBody";
269        let headers = extract_headers(raw, &["From"]);
270        assert_eq!(headers.len(), 1);
271        assert!(!headers.contains_key("X-Other"));
272    }
273
274    #[test]
275    fn extract_headers_keeps_first_occurrence_of_a_duplicated_header() {
276        let raw = b"Received: first\r\nReceived: second\r\n\r\nBody";
277        let headers = extract_headers(raw, &["Received"]);
278        assert_eq!(headers.get("Received").map(String::as_str), Some("first"));
279    }
280
281    #[test]
282    fn extract_headers_returns_empty_map_when_none_present() {
283        let raw = b"X-Only: value\r\n\r\nBody";
284        let headers = extract_headers(raw, &["From", "Subject"]);
285        assert!(headers.is_empty());
286    }
287
288    // ── extract_attachment_filenames ────────────────────────────────────
289
290    #[test]
291    fn extract_attachment_filenames_counts_and_names_a_quoted_filename() {
292        let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
293--B\r\nContent-Type: text/plain\r\n\r\nHello\r\n\
294--B\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=\"report.pdf\"\r\n\r\ndata\r\n\
295--B--\r\n";
296        let summary = extract_attachment_filenames(raw);
297        assert_eq!(summary.count, 1);
298        assert_eq!(summary.filenames, vec!["report.pdf".to_string()]);
299    }
300
301    #[test]
302    fn extract_attachment_filenames_decodes_rfc2231_encoded_filename() {
303        let raw = b"Content-Disposition: attachment; filename*=UTF-8''report%20Q3.pdf\r\n\r\ndata";
304        let summary = extract_attachment_filenames(raw);
305        assert_eq!(summary.count, 1);
306        assert_eq!(summary.filenames, vec!["report Q3.pdf".to_string()]);
307    }
308
309    #[test]
310    fn extract_attachment_filenames_prefers_rfc2231_over_plain_filename_when_both_present() {
311        let raw = b"Content-Disposition: attachment; filename=\"fallback.pdf\"; filename*=UTF-8''real.pdf\r\n\r\ndata";
312        let summary = extract_attachment_filenames(raw);
313        assert_eq!(summary.filenames, vec!["real.pdf".to_string()]);
314    }
315
316    #[test]
317    fn extract_attachment_filenames_counts_unnamed_attachment() {
318        let raw = b"Content-Disposition: attachment\r\n\r\ndata";
319        let summary = extract_attachment_filenames(raw);
320        assert_eq!(summary.count, 1);
321        assert!(summary.filenames.is_empty());
322    }
323
324    #[test]
325    fn extract_attachment_filenames_counts_multiple_attachments() {
326        let raw = b"Content-Disposition: attachment; filename=\"a.pdf\"\r\n\r\n\
327--B\r\nContent-Disposition: attachment; filename=\"b.pdf\"\r\n\r\ndata";
328        let summary = extract_attachment_filenames(raw);
329        assert_eq!(summary.count, 2);
330        assert_eq!(
331            summary.filenames,
332            vec!["a.pdf".to_string(), "b.pdf".to_string()]
333        );
334    }
335
336    #[test]
337    fn extract_attachment_filenames_ignores_inline_disposition() {
338        let raw = b"Content-Disposition: inline; filename=\"logo.png\"\r\n\r\ndata";
339        let summary = extract_attachment_filenames(raw);
340        assert_eq!(summary.count, 0);
341        assert!(summary.filenames.is_empty());
342    }
343
344    #[test]
345    fn extract_attachment_filenames_returns_empty_summary_for_plain_text_message() {
346        let raw = b"Subject: Hi\r\nFrom: a@example.com\r\n\r\nJust text, no attachments.";
347        let summary = extract_attachment_filenames(raw);
348        assert_eq!(summary, AttachmentSummary::default());
349    }
350
351    #[test]
352    fn extract_attachment_filenames_handles_folded_content_disposition_header() {
353        let raw = b"Content-Disposition: attachment;\r\n filename=\"wrapped-name.pdf\"\r\n\r\ndata";
354        let summary = extract_attachment_filenames(raw);
355        assert_eq!(summary.filenames, vec!["wrapped-name.pdf".to_string()]);
356    }
357
358    #[test]
359    fn extract_attachment_filenames_handles_unquoted_filename() {
360        let raw = b"Content-Disposition: attachment; filename=report.pdf\r\n\r\ndata";
361        let summary = extract_attachment_filenames(raw);
362        assert_eq!(summary.filenames, vec!["report.pdf".to_string()]);
363    }
364}