use crate::backend::cfb::CompoundFile;
pub(crate) struct ProjectedMsg {
pub(crate) rfc822: Vec<u8>,
pub(crate) attachment_labels: Vec<String>,
}
pub(crate) fn project(data: &[u8]) -> Option<ProjectedMsg> {
let cfb = CompoundFile::open(data)?;
Some(project_entries(&cfb, &cfb.children_of(None), ROOT_HEADER))
}
const ROOT_HEADER: usize = 32;
const EMBEDDED_HEADER: usize = 24;
const SUB_HEADER: usize = 8;
pub(crate) fn project_entries(cfb: &CompoundFile, root: &[usize], header: usize) -> ProjectedMsg {
let prop = |id: &str| -> Option<String> { read_string(cfb, root, id) };
let subject = prop("0037");
let from = address(
prop("0C1A").or_else(|| prop("0042")),
prop("0C1F").or_else(|| prop("0065")),
);
let mut to: Vec<String> = Vec::new();
let mut cc: Vec<String> = Vec::new();
for idx in root.iter().copied() {
if !cfb.is_storage(idx) || !cfb.entry_name(idx).starts_with("__recip_version1.0_#") {
continue;
}
let kids = cfb.children_of(Some(idx));
let addr = address(
read_string(cfb, &kids, "3001"),
read_string(cfb, &kids, "39FE").or_else(|| read_string(cfb, &kids, "3003")),
);
let Some(addr) = addr else { continue };
match fixed_u32(cfb, &kids, SUB_HEADER, 0x0C15).unwrap_or(1) {
2 => cc.push(addr),
3 => {} _ => to.push(addr),
}
}
let date = fixed_filetime(cfb, root, header, 0x0039)
.or_else(|| fixed_filetime(cfb, root, header, 0x0E06))
.map(rfc2822_utc);
let body = read_string(cfb, root, "1000").unwrap_or_default();
let mut labels: Vec<String> = Vec::new();
for idx in attachment_storages(cfb, root) {
let kids = cfb.children_of(Some(idx));
let name = attachment_name(cfb, &kids)
.unwrap_or_else(|| format!("attachment-{}", labels.len() + 1));
let label = match attachment_mime(cfb, &kids) {
Some(mime) => format!("{name} ({mime})"),
None => name,
};
labels.push(label);
}
let mut out = String::new();
if let Some(f) = from {
out.push_str(&format!("From: {f}\r\n"));
}
if !to.is_empty() {
out.push_str(&format!("To: {}\r\n", to.join(", ")));
}
if !cc.is_empty() {
out.push_str(&format!("Cc: {}\r\n", cc.join(", ")));
}
if let Some(s) = subject {
out.push_str(&format!("Subject: {s}\r\n"));
}
if let Some(d) = date {
out.push_str(&format!("Date: {d}\r\n"));
}
out.push_str("MIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n");
out.push_str(&body);
ProjectedMsg {
rfc822: out.into_bytes(),
attachment_labels: labels,
}
}
fn attachment_storages(cfb: &CompoundFile, root: &[usize]) -> Vec<usize> {
root.iter()
.copied()
.filter(|&idx| {
cfb.is_storage(idx) && cfb.entry_name(idx).starts_with("__attach_version1.0_#")
})
.collect()
}
fn attachment_name(cfb: &CompoundFile, kids: &[usize]) -> Option<String> {
["3707", "3704", "3001"]
.iter()
.find_map(|id| read_string(cfb, kids, id))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn attachment_mime(cfb: &CompoundFile, kids: &[usize]) -> Option<String> {
read_string(cfb, kids, "370E")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub(crate) struct MsgAttachment {
pub(crate) name: Option<String>,
pub(crate) mime: Option<String>,
pub(crate) method: u32,
pub(crate) payload: Option<Vec<u8>>,
pub(crate) inline: bool,
}
pub(crate) fn attachments(data: &[u8]) -> Option<Vec<MsgAttachment>> {
let cfb = CompoundFile::open(data)?;
let root = cfb.children_of(None);
let mut out = Vec::new();
for idx in attachment_storages(&cfb, &root) {
let kids = cfb.children_of(Some(idx));
let method = fixed_u32(&cfb, &kids, SUB_HEADER, 0x3705).unwrap_or(1);
let (payload, mime, name) = match method {
5 => {
let storage = kids
.iter()
.copied()
.find(|&k| cfb.is_storage(k) && cfb.entry_name(k) == "__substg1.0_3701000D");
let projected = storage
.map(|s| project_entries(&cfb, &cfb.children_of(Some(s)), EMBEDDED_HEADER));
(
projected.map(|p| p.rfc822),
Some("message/rfc822".to_string()),
attachment_name(&cfb, &kids)
.map(|n| format!("{}.eml", n.replace(['/', '\\'], "-"))),
)
}
1 => {
let data = kids
.iter()
.find(|&&k| cfb.entry_name(k) == "__substg1.0_37010102")
.and_then(|&k| cfb.stream_by_index(k));
(
data,
attachment_mime(&cfb, &kids),
attachment_name(&cfb, &kids),
)
}
_ => (
None,
attachment_mime(&cfb, &kids),
attachment_name(&cfb, &kids),
),
};
let hidden = fixed_u32(&cfb, &kids, SUB_HEADER, 0x7FFE).is_some_and(|v| v & 0xFF != 0);
let mhtml_ref = fixed_u32(&cfb, &kids, SUB_HEADER, 0x3714).is_some_and(|f| f & 4 != 0);
let content_id = read_string(&cfb, &kids, "3712").is_some_and(|s| !s.trim().is_empty());
out.push(MsgAttachment {
name,
mime,
method,
payload,
inline: hidden || mhtml_ref || content_id,
});
}
Some(out)
}
fn address(name: Option<String>, email: Option<String>) -> Option<String> {
let name = name.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let email = email
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
match (name, email) {
(Some(n), Some(e)) => Some(format!("{n} <{e}>")),
(None, Some(e)) => Some(e),
(Some(n), None) => Some(n),
(None, None) => None,
}
}
fn read_string(cfb: &CompoundFile, entries: &[usize], id: &str) -> Option<String> {
let find = |suffix: &str| -> Option<Vec<u8>> {
let want = format!("__substg1.0_{id}{suffix}");
entries
.iter()
.find(|&&i| cfb.entry_name(i) == want)
.and_then(|&i| cfb.stream_by_index(i))
};
if let Some(b) = find("001F") {
let s: String = b
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.map(|u| char::from_u32(u as u32).unwrap_or('\u{FFFD}'))
.collect();
return Some(s.trim_end_matches('\0').to_string());
}
if let Some(b) = find("001E") {
let s: String = b.iter().map(|&x| super::doc::cp1252(x)).collect();
return Some(s.trim_end_matches('\0').to_string());
}
None
}
fn fixed_raw(cfb: &CompoundFile, entries: &[usize], header: usize, id: u16) -> Option<[u8; 8]> {
let stream = entries
.iter()
.find(|&&i| cfb.entry_name(i) == "__properties_version1.0")
.and_then(|&i| cfb.stream_by_index(i))?;
find_fixed(&stream, header, id)
}
fn find_fixed(stream: &[u8], header: usize, id: u16) -> Option<[u8; 8]> {
let body = stream.get(header..)?;
body.chunks_exact(16)
.find(|rec| {
let ptype = u16::from_le_bytes([rec[0], rec[1]]);
let rid = u16::from_le_bytes([rec[2], rec[3]]);
rid == id && is_fixed_type(ptype)
})
.and_then(|rec| rec[8..16].try_into().ok())
}
fn is_fixed_type(ptype: u16) -> bool {
matches!(
ptype,
0x0002 | 0x0003 | 0x0004 | 0x0005 | 0x0006 | 0x0007 | 0x000A | 0x000B | 0x0014 | 0x0040
)
}
fn fixed_u32(cfb: &CompoundFile, entries: &[usize], header: usize, id: u16) -> Option<u32> {
fixed_raw(cfb, entries, header, id).map(|v| u32::from_le_bytes([v[0], v[1], v[2], v[3]]))
}
fn fixed_filetime(cfb: &CompoundFile, entries: &[usize], header: usize, id: u16) -> Option<i64> {
let ticks = fixed_raw(cfb, entries, header, id).map(u64::from_le_bytes)?;
if ticks == 0 {
return None;
}
Some((ticks / 10_000_000) as i64 - 11_644_473_600)
}
fn rfc2822_utc(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let (h, m, s) = (tod / 3600, (tod / 60) % 60, tod % 60);
let weekday = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"][days.rem_euclid(7) as usize];
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { y + 1 } else { y };
let month_name = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
][(month - 1) as usize];
format!("{weekday}, {day} {month_name} {year} {h:02}:{m:02}:{s:02} +0000")
}
#[cfg(test)]
mod tests {
#[test]
fn fixed_property_scan_keeps_its_alignment() {
let mut stream = vec![0u8; 8];
let rec = |ptype: u16, id: u16, value: [u8; 8]| {
let mut r = Vec::new();
r.extend_from_slice(&ptype.to_le_bytes());
r.extend_from_slice(&id.to_le_bytes());
r.extend_from_slice(&6u32.to_le_bytes());
r.extend_from_slice(&value);
r
};
stream.extend(rec(0x0040, 0x3007, 0x01DC_3A2B_3705_1240u64.to_le_bytes()));
stream.extend(rec(
0x0003,
0x3705,
1u32.to_le_bytes()
.into_iter()
.chain([0; 4])
.collect::<Vec<_>>()
.try_into()
.unwrap(),
));
stream.extend(rec(0x000B, 0x7FFE, [0; 8]));
let method = super::find_fixed(&stream, 8, 0x3705).unwrap();
assert_eq!(u32::from_le_bytes(method[..4].try_into().unwrap()), 1);
assert_eq!(super::find_fixed(&stream, 32, 0x3705), None);
assert_eq!(super::find_fixed(&stream, 8, 0x7FFE), Some([0; 8]));
assert_eq!(super::find_fixed(&stream, 8, 0x0039), None);
}
#[test]
fn civil_conversion_matches_known_dates() {
assert_eq!(
super::rfc2822_utc(1_779_273_000),
"Wed, 20 May 2026 10:30:00 +0000"
);
assert_eq!(super::rfc2822_utc(0), "Thu, 1 Jan 1970 00:00:00 +0000");
}
}