use std::collections::HashSet;
use mail_parser::{ContentType, MessageParser, MimeHeaders};
use crate::utils::path::attachment_filename;
pub(crate) struct ExtractedAttachment {
pub(crate) filename: String,
pub(crate) contents: Vec<u8>,
}
pub(crate) fn extract_attachments(raw: &[u8]) -> Vec<ExtractedAttachment> {
let Some(message) = MessageParser::default().parse(raw) else {
return Vec::new();
};
let mut seen = HashSet::new();
message
.attachments()
.filter(|part| {
part.content_disposition()
.is_some_and(ContentType::is_attachment)
})
.enumerate()
.map(|(index, part)| {
let name =
attachment_filename(part.attachment_name().unwrap_or(""), &index.to_string());
ExtractedAttachment {
filename: dedupe_filename(&mut seen, name),
contents: part.contents().to_vec(),
}
})
.collect()
}
fn dedupe_filename(seen: &mut HashSet<String>, name: String) -> String {
if seen.insert(name.to_ascii_lowercase()) {
return name;
}
let (stem, ext) = match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => (stem.to_string(), Some(ext.to_string())),
_ => (name.clone(), None),
};
let mut n = 1u32;
loop {
let candidate = match &ext {
Some(ext) => format!("{stem}-{n}.{ext}"),
None => format!("{stem}-{n}"),
};
if seen.insert(candidate.to_ascii_lowercase()) {
return candidate;
}
n += 1;
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use base64::Engine as _;
#[test]
fn extract_attachments_decodes_base64_binary_content() {
let bytes: &[u8] = &[0x50, 0x44, 0x46, 0x00, 0x01, 0x02, 0xff];
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
let raw = format!(
"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-Transfer-Encoding: base64\r\n\
Content-Disposition: attachment; filename=\"report.pdf\"\r\n\r\n{encoded}\r\n\
--B--\r\n"
);
let extracted = extract_attachments(raw.as_bytes());
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].filename, "report.pdf");
assert_eq!(extracted[0].contents, bytes);
}
#[test]
fn extract_attachments_decodes_quoted_printable_content() {
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: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\
Content-Disposition: attachment; filename=\"notes.txt\"\r\n\r\nCaf=C3=A9\r\n\
--B--\r\n";
let extracted = extract_attachments(raw);
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].filename, "notes.txt");
assert_eq!(extracted[0].contents, "Café".as_bytes());
}
#[test]
fn extract_attachments_decodes_rfc2231_percent_encoded_filename() {
let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
--B\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename*=UTF-8''report%20Q3.pdf\r\n\r\ndata\r\n\
--B--\r\n";
let extracted = extract_attachments(raw);
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].filename, "report Q3.pdf");
}
#[test]
fn extract_attachments_resolves_rfc2231_continuation_filename() {
let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
--B\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename*0=\"report_\"; filename*1=\"Q3.pdf\"\r\n\r\ndata\r\n\
--B--\r\n";
let extracted = extract_attachments(raw);
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].filename, "report_Q3.pdf");
}
#[test]
fn extract_attachments_synthesizes_name_for_unnamed_attachment() {
let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
--B\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment\r\n\r\ndata\r\n\
--B--\r\n";
let extracted = extract_attachments(raw);
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].filename, "attachment-0");
}
#[test]
fn extract_attachments_excludes_inline_disposition() {
let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
--B\r\nContent-Type: image/png\r\nContent-Disposition: inline; filename=\"logo.png\"\r\n\r\ndata\r\n\
--B--\r\n";
let extracted = extract_attachments(raw);
assert!(extracted.is_empty());
}
#[test]
fn extract_attachments_dedupes_same_named_attachments_within_one_message() {
let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
--B\r\nContent-Type: image/png\r\nContent-Disposition: attachment; filename=\"image.png\"\r\n\r\nfirst\r\n\
--B\r\nContent-Type: image/png\r\nContent-Disposition: attachment; filename=\"image.png\"\r\n\r\nsecond\r\n\
--B--\r\n";
let extracted = extract_attachments(raw);
assert_eq!(extracted.len(), 2);
assert_eq!(extracted[0].filename, "image.png");
assert_eq!(extracted[1].filename, "image-1.png");
}
#[test]
fn extract_attachments_dedupes_names_differing_only_by_case() {
let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
--B\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=\"Report.PDF\"\r\n\r\nfirst\r\n\
--B\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=\"report.pdf\"\r\n\r\nsecond\r\n\
--B--\r\n";
let extracted = extract_attachments(raw);
assert_eq!(extracted.len(), 2);
assert_eq!(extracted[0].filename, "Report.PDF");
assert_eq!(extracted[1].filename, "report-1.pdf");
}
#[test]
fn extract_attachments_sanitizes_path_traversal_filename() {
let raw = b"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n\
--B\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=\"../../etc/passwd\"\r\n\r\ndata\r\n\
--B--\r\n";
let extracted = extract_attachments(raw);
assert_eq!(extracted.len(), 1);
assert_eq!(extracted[0].filename, "passwd");
}
#[test]
fn extract_attachments_returns_empty_vec_for_unparseable_message() {
assert!(extract_attachments(b"").is_empty());
}
#[test]
fn extract_attachments_returns_empty_vec_for_plain_text_message() {
let raw = b"Subject: Hi\r\nFrom: a@example.com\r\n\r\nJust text, no attachments.";
assert!(extract_attachments(raw).is_empty());
}
}