use anyhow::{Context, Result};
use base64::Engine as _;
use percent_encoding::percent_decode_str;
use std::collections::HashMap;
use crate::gmail::types::Message;
pub(crate) fn decode_raw_message(message: &Message) -> Result<Vec<u8>> {
let raw = message
.raw
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Gmail's response for `--detail raw` had no `raw` field"))?;
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(raw.trim_end_matches('='))
.context("Failed to base64url-decode the raw RFC 2822 message")
}
pub(crate) fn extract_headers(raw: &[u8], names: &[&str]) -> HashMap<String, String> {
let text = String::from_utf8_lossy(raw);
let mut result = HashMap::new();
let mut current: Option<(String, String)> = None;
let flush = |current: Option<(String, String)>, result: &mut HashMap<String, String>| {
if let Some((name, value)) = current {
let matched = names.iter().find(|n| n.eq_ignore_ascii_case(&name));
if let Some(&matched_name) = matched {
result.entry(matched_name.to_string()).or_insert(value);
}
}
};
for line in text.split(['\n']) {
let line = line.strip_suffix('\r').unwrap_or(line);
if line.is_empty() {
break; }
if line.starts_with(' ') || line.starts_with('\t') {
if let Some((_, value)) = current.as_mut() {
value.push(' ');
value.push_str(line.trim());
}
continue;
}
if let Some((name, value)) = line.split_once(':') {
flush(current.take(), &mut result);
current = Some((name.trim().to_string(), value.trim().to_string()));
}
}
flush(current, &mut result);
result
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct AttachmentSummary {
pub(crate) count: usize,
pub(crate) filenames: Vec<String>,
}
pub(crate) fn extract_attachment_filenames(raw: &[u8]) -> AttachmentSummary {
let text = String::from_utf8_lossy(raw);
let mut summary = AttachmentSummary::default();
let mut current: Option<String> = None;
let flush = |current: Option<String>, summary: &mut AttachmentSummary| {
let Some(value) = current else { return };
if !value
.trim_start()
.to_ascii_lowercase()
.starts_with("attachment")
{
return;
}
summary.count += 1;
if let Some(name) = parse_filename_param(&value) {
summary.filenames.push(name);
}
};
for line in text.split('\n') {
let line = line.strip_suffix('\r').unwrap_or(line);
if line.starts_with(' ') || line.starts_with('\t') {
if let Some(value) = current.as_mut() {
value.push(' ');
value.push_str(line.trim());
}
continue;
}
flush(current.take(), &mut summary);
if let Some((name, value)) = line.split_once(':') {
if name.trim().eq_ignore_ascii_case("Content-Disposition") {
current = Some(value.trim().to_string());
}
}
}
flush(current, &mut summary);
summary
}
fn parse_filename_param(value: &str) -> Option<String> {
let mut plain = None;
let mut encoded = None;
for part in value.split(';') {
let part = part.trim();
if let Some(rest) = part.strip_prefix("filename*=") {
encoded = Some(rest.to_string());
} else if let Some(rest) = part.strip_prefix("filename=") {
plain = Some(rest.trim_matches('"').to_string());
}
}
if let Some(rest) = encoded {
let percent_encoded = rest.rsplit('\'').next().unwrap_or(&rest);
return Some(
percent_decode_str(percent_encoded)
.decode_utf8_lossy()
.into_owned(),
);
}
plain
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn decode_raw_message_decodes_unpadded_base64url() {
let source = "From: a@example.com\r\nSubject: Hi\r\n\r\nBody text.";
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(source);
let message = Message {
id: "m1".to_string(),
raw: Some(encoded),
..Default::default()
};
let decoded = decode_raw_message(&message).unwrap();
assert_eq!(decoded, source.as_bytes());
}
#[test]
fn decode_raw_message_tolerates_padded_base64url() {
let source = "From: a@example.com\r\n\r\nBody.";
let encoded = base64::engine::general_purpose::URL_SAFE.encode(source);
let message = Message {
id: "m1".to_string(),
raw: Some(encoded),
..Default::default()
};
let decoded = decode_raw_message(&message).unwrap();
assert_eq!(decoded, source.as_bytes());
}
#[test]
fn decode_raw_message_errors_when_raw_field_absent() {
let message = Message {
id: "m1".to_string(),
..Default::default()
};
let err = decode_raw_message(&message).unwrap_err();
assert!(err.to_string().contains("no `raw` field"));
}
#[test]
fn decode_raw_message_errors_on_malformed_base64() {
let message = Message {
id: "m1".to_string(),
raw: Some("not valid base64url!!!".to_string()),
..Default::default()
};
let err = decode_raw_message(&message).unwrap_err();
assert!(err.to_string().contains("base64url-decode"));
}
#[test]
fn extract_headers_matches_requested_names_case_insensitively() {
let raw = b"From: a@example.com\r\nSUBJECT: Hello\r\nMessage-Id: <1@x>\r\n\r\nBody";
let headers = extract_headers(raw, &["From", "Subject", "Message-Id"]);
assert_eq!(
headers.get("From").map(String::as_str),
Some("a@example.com")
);
assert_eq!(headers.get("Subject").map(String::as_str), Some("Hello"));
assert_eq!(headers.get("Message-Id").map(String::as_str), Some("<1@x>"));
}
#[test]
fn extract_headers_stops_at_first_blank_line() {
let raw = b"From: a@example.com\r\n\r\nSubject: not-a-header-its-body";
let headers = extract_headers(raw, &["Subject"]);
assert!(!headers.contains_key("Subject"));
}
#[test]
fn extract_headers_unfolds_continuation_lines() {
let raw = b"Subject: Hello\r\n world\r\n\r\nBody";
let headers = extract_headers(raw, &["Subject"]);
assert_eq!(
headers.get("Subject").map(String::as_str),
Some("Hello world")
);
}
#[test]
fn extract_headers_ignores_unrequested_headers() {
let raw = b"From: a@example.com\r\nX-Other: whatever\r\n\r\nBody";
let headers = extract_headers(raw, &["From"]);
assert_eq!(headers.len(), 1);
assert!(!headers.contains_key("X-Other"));
}
#[test]
fn extract_headers_keeps_first_occurrence_of_a_duplicated_header() {
let raw = b"Received: first\r\nReceived: second\r\n\r\nBody";
let headers = extract_headers(raw, &["Received"]);
assert_eq!(headers.get("Received").map(String::as_str), Some("first"));
}
#[test]
fn extract_headers_returns_empty_map_when_none_present() {
let raw = b"X-Only: value\r\n\r\nBody";
let headers = extract_headers(raw, &["From", "Subject"]);
assert!(headers.is_empty());
}
#[test]
fn extract_attachment_filenames_counts_and_names_a_quoted_filename() {
let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
--B\r\nContent-Type: text/plain\r\n\r\nHello\r\n\
--B\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=\"report.pdf\"\r\n\r\ndata\r\n\
--B--\r\n";
let summary = extract_attachment_filenames(raw);
assert_eq!(summary.count, 1);
assert_eq!(summary.filenames, vec!["report.pdf".to_string()]);
}
#[test]
fn extract_attachment_filenames_decodes_rfc2231_encoded_filename() {
let raw = b"Content-Disposition: attachment; filename*=UTF-8''report%20Q3.pdf\r\n\r\ndata";
let summary = extract_attachment_filenames(raw);
assert_eq!(summary.count, 1);
assert_eq!(summary.filenames, vec!["report Q3.pdf".to_string()]);
}
#[test]
fn extract_attachment_filenames_prefers_rfc2231_over_plain_filename_when_both_present() {
let raw = b"Content-Disposition: attachment; filename=\"fallback.pdf\"; filename*=UTF-8''real.pdf\r\n\r\ndata";
let summary = extract_attachment_filenames(raw);
assert_eq!(summary.filenames, vec!["real.pdf".to_string()]);
}
#[test]
fn extract_attachment_filenames_counts_unnamed_attachment() {
let raw = b"Content-Disposition: attachment\r\n\r\ndata";
let summary = extract_attachment_filenames(raw);
assert_eq!(summary.count, 1);
assert!(summary.filenames.is_empty());
}
#[test]
fn extract_attachment_filenames_counts_multiple_attachments() {
let raw = b"Content-Disposition: attachment; filename=\"a.pdf\"\r\n\r\n\
--B\r\nContent-Disposition: attachment; filename=\"b.pdf\"\r\n\r\ndata";
let summary = extract_attachment_filenames(raw);
assert_eq!(summary.count, 2);
assert_eq!(
summary.filenames,
vec!["a.pdf".to_string(), "b.pdf".to_string()]
);
}
#[test]
fn extract_attachment_filenames_ignores_inline_disposition() {
let raw = b"Content-Disposition: inline; filename=\"logo.png\"\r\n\r\ndata";
let summary = extract_attachment_filenames(raw);
assert_eq!(summary.count, 0);
assert!(summary.filenames.is_empty());
}
#[test]
fn extract_attachment_filenames_returns_empty_summary_for_plain_text_message() {
let raw = b"Subject: Hi\r\nFrom: a@example.com\r\n\r\nJust text, no attachments.";
let summary = extract_attachment_filenames(raw);
assert_eq!(summary, AttachmentSummary::default());
}
#[test]
fn extract_attachment_filenames_handles_folded_content_disposition_header() {
let raw = b"Content-Disposition: attachment;\r\n filename=\"wrapped-name.pdf\"\r\n\r\ndata";
let summary = extract_attachment_filenames(raw);
assert_eq!(summary.filenames, vec!["wrapped-name.pdf".to_string()]);
}
#[test]
fn extract_attachment_filenames_handles_unquoted_filename() {
let raw = b"Content-Disposition: attachment; filename=report.pdf\r\n\r\ndata";
let summary = extract_attachment_filenames(raw);
assert_eq!(summary.filenames, vec!["report.pdf".to_string()]);
}
}