use alloc::{
format,
string::{String, ToString},
vec::Vec,
};
use io_replica::placement::{ReplicaLinkId, ReplicaMeta, ReplicaSortKey};
use serde::{Deserialize, Serialize};
use crate::conventions::{PimdirDerivation, time, unfold};
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct PimdirMailMeta {
pub v: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub in_reply_to: Vec<String>,
#[serde(default)]
pub subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
pub fn derive(body: &[u8]) -> PimdirDerivation {
let headers = headers(body);
let message_id = header(&headers, "message-id").and_then(strip_angles);
let date = header(&headers, "date").and_then(rfc3339_from_rfc5322);
let subject = header(&headers, "subject").unwrap_or_default().to_string();
let from = header(&headers, "from").and_then(first_address);
let to = header(&headers, "to").and_then(first_address);
let link_id = match &message_id {
Some(id) => id.clone(),
None => format!(
"alt:{subject}|{}|{}",
date.as_deref().unwrap_or_default(),
from.as_deref().unwrap_or_default(),
),
};
let meta = PimdirMailMeta {
v: 1,
message_id,
in_reply_to: header(&headers, "in-reply-to")
.map(message_ids)
.unwrap_or_default(),
subject,
from,
to,
date: date.clone(),
size: Some(body.len() as u64),
};
PimdirDerivation {
link_id: ReplicaLinkId(link_id),
meta: ReplicaMeta(serde_json::to_string(&meta).unwrap_or_default()),
sort_key: ReplicaSortKey(date.unwrap_or_default()),
}
}
fn headers(body: &[u8]) -> Vec<(String, String)> {
unfold(body, true)
.into_iter()
.take_while(|line| !line.is_empty())
.filter_map(|line| {
let (name, value) = line.split_once(':')?;
Some((name.trim().to_lowercase(), value.trim().to_string()))
})
.collect()
}
fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(header, _)| header == name)
.map(|(_, value)| value.as_str())
}
fn strip_angles(raw: &str) -> Option<String> {
let trimmed = raw.trim();
let inner = trimmed
.strip_prefix('<')
.and_then(|rest| rest.strip_suffix('>'))
.unwrap_or(trimmed)
.trim();
(!inner.is_empty()).then(|| inner.to_string())
}
fn message_ids(raw: &str) -> Vec<String> {
if raw.contains('<') {
return raw
.split('<')
.filter_map(|rest| rest.split_once('>'))
.filter_map(|(id, _)| strip_angles(id))
.collect();
}
raw.split_whitespace().filter_map(strip_angles).collect()
}
fn first_address(raw: &str) -> Option<String> {
let mut quoted = false;
let mut angled = false;
let mut first = raw;
for (at, char) in raw.char_indices() {
match char {
'"' => quoted = !quoted,
'<' if !quoted => angled = true,
'>' if !quoted => angled = false,
',' if !quoted && !angled => {
first = &raw[..at];
break;
}
_ => {}
}
}
let address = match (first.find('<'), first.find('>')) {
(Some(open), Some(close)) if open < close => &first[open + 1..close],
_ => first,
};
let address = address.trim();
(!address.is_empty()).then(|| address.to_string())
}
fn rfc3339_from_rfc5322(raw: &str) -> Option<String> {
let raw = raw.split(&['(', ';'][..]).next().unwrap_or_default();
let rest = match raw.split_once(',') {
Some((_, rest)) => rest,
None => raw,
};
let mut fields = rest.split_whitespace();
let day: u32 = fields.next()?.parse().ok()?;
let month = month(fields.next()?)?;
let year = year(fields.next()?)?;
let mut clock = fields.next()?.split(':');
let hour: u32 = clock.next()?.parse().ok()?;
let minute: u32 = clock.next()?.parse().ok()?;
let second: u32 = clock.next().unwrap_or("0").parse().ok()?;
if day == 0 || day > 31 || hour > 24 || minute > 59 || second > 60 {
return None;
}
let offset = zone(fields.next().unwrap_or("-0000"))?;
Some(time::rfc3339(
time::unix(year, month, day, hour, minute, second) - offset,
))
}
fn month(name: &str) -> Option<u32> {
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
let name = name.to_lowercase();
MONTHS
.iter()
.position(|month| *month == name)
.map(|index| index as u32 + 1)
}
fn year(raw: &str) -> Option<i32> {
let year: i32 = raw.parse().ok()?;
match raw.len() {
2 if year < 50 => Some(2000 + year),
2 => Some(1900 + year),
3 => Some(1900 + year),
_ => Some(year),
}
}
fn zone(raw: &str) -> Option<i64> {
let bytes = raw.as_bytes();
if matches!(bytes.first(), Some(b'+' | b'-')) && bytes.len() >= 5 {
let hours = time::digits(bytes, 1, 2)? as i64;
let minutes = time::digits(bytes, 3, 2)? as i64;
let offset = hours * 3_600 + minutes * 60;
return Some(if bytes[0] == b'-' { -offset } else { offset });
}
match raw.to_uppercase().as_str() {
"UT" | "GMT" | "Z" => Some(0),
"EDT" => Some(-4 * 3_600),
"EST" | "CDT" => Some(-5 * 3_600),
"CST" | "MDT" => Some(-6 * 3_600),
"MST" | "PDT" => Some(-7 * 3_600),
"PST" => Some(-8 * 3_600),
name if name.len() == 1 => Some(0),
_ => None,
}
}