omni_dev/gmail/
raw_message.rs1use anyhow::{Context, Result};
12use base64::Engine as _;
13use percent_encoding::percent_decode_str;
14use std::collections::HashMap;
15
16use crate::gmail::types::Message;
17
18pub(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
33pub(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; }
62 if line.starts_with(' ') || line.starts_with('\t') {
63 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
82pub(crate) struct AttachmentSummary {
83 pub(crate) count: usize,
86 pub(crate) filenames: Vec<String>,
89}
90
91pub(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 if let Some(value) = current.as_mut() {
132 value.push(' ');
133 value.push_str(line.trim());
134 }
135 continue;
136 }
137 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
151fn 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 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 #[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 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 #[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 #[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}