pub mod mail;
pub mod merge;
use anyhow::{Result, bail};
#[cfg(feature = "dav")]
use io_pimdir::summary::{calendar, contact};
use io_pimdir::{placement::PimdirLinkId, remote::PimdirTier, summary::PimdirDerivation};
use crate::item::summary::ItemSummary;
const MINT_PREFIX: &str = "dup:";
const MINT_SEPARATOR: char = '#';
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct LinkId<'a> {
pub hint: Option<&'a str>,
pub mint: Option<&'a str>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Kind {
Mail,
#[cfg(feature = "dav")]
Vcard,
#[cfg(feature = "dav")]
Ical,
}
impl Kind {
pub fn from_media_type(media_type: &str) -> Option<Self> {
match media_type {
"message/rfc822" => Some(Self::Mail),
#[cfg(feature = "dav")]
"text/vcard" => Some(Self::Vcard),
#[cfg(feature = "dav")]
"text/calendar" => Some(Self::Ical),
_ => None,
}
}
pub fn media_type(self) -> &'static str {
match self {
Self::Mail => "message/rfc822",
#[cfg(feature = "dav")]
Self::Vcard => "text/vcard",
#[cfg(feature = "dav")]
Self::Ical => "text/calendar",
}
}
pub fn extension(self) -> &'static str {
match self {
Self::Mail => "eml",
#[cfg(feature = "dav")]
Self::Vcard => "vcf",
#[cfg(feature = "dav")]
Self::Ical => "ics",
}
}
pub fn parse_body(self, raw: &[u8], size: u64) -> PimdirDerivation {
match self {
Self::Mail => mail::parse_body(raw, size),
#[cfg(feature = "dav")]
Self::Vcard => contact::derive(raw),
#[cfg(feature = "dav")]
Self::Ical => calendar::derive(raw),
}
}
pub fn validate_body(self, body: &[u8], link_id: &PimdirLinkId) -> Result<()> {
let Some(component) = self.component() else {
bail!("Mail bodies are immutable, so no body settles a message");
};
if !wrapped_in(body, component) {
bail!(
"A {} body opens with BEGIN:{component} and closes with END:{component}",
self.media_type()
);
}
let derived = self.parse_body(body, body.len() as u64).link_id;
let stated = self.split_link_id(&derived).hint;
let bound = self.split_link_id(link_id).hint;
match (bound, stated) {
(bound, stated) if bound == stated => Ok(()),
(Some(bound), Some(stated)) => {
bail!("A settled body keeps the item's UID {bound}, and this one states {stated}")
}
(Some(bound), None) => {
bail!("A settled body keeps the item's UID {bound}, and this one states none")
}
(None, Some(stated)) => bail!(
"The item states no UID of its own, and a settled body cannot give it {stated}"
),
(None, None) => unreachable!("two absent hints compare equal"),
}
}
fn component(self) -> Option<&'static str> {
match self {
Self::Mail => None,
#[cfg(feature = "dav")]
Self::Vcard => Some("VCARD"),
#[cfg(feature = "dav")]
Self::Ical => Some("VCALENDAR"),
}
}
pub fn split_link_id<'l>(self, link_id: &'l PimdirLinkId) -> LinkId<'l> {
let Some(minted) = link_id.0.strip_prefix(MINT_PREFIX) else {
return LinkId {
hint: self.hint(&link_id.0),
mint: None,
};
};
let (hint, mint) = minted.rsplit_once(MINT_SEPARATOR).unwrap_or(("", minted));
LinkId {
hint: self.hint(hint),
mint: Some(mint),
}
}
fn hint(self, key: &str) -> Option<&str> {
let fallback = match self {
Self::Mail => "alt:",
#[cfg(feature = "dav")]
Self::Vcard | Self::Ical => "hash:",
};
(!key.is_empty() && !key.starts_with(fallback)).then_some(key)
}
pub fn probe_tier(self) -> PimdirTier {
match self {
Self::Mail => PimdirTier::Meta,
#[cfg(feature = "dav")]
Self::Vcard | Self::Ical => PimdirTier::Full,
}
}
pub fn parse_summary(self, summary: &ItemSummary) -> Option<PimdirDerivation> {
match self {
Self::Mail => Some(mail::parse_summary(summary)),
#[cfg(feature = "dav")]
Self::Vcard | Self::Ical => None,
}
}
}
fn wrapped_in(body: &[u8], component: &str) -> bool {
let text = String::from_utf8_lossy(body);
let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty());
let opens = lines
.next()
.is_some_and(|line| line.eq_ignore_ascii_case(&format!("BEGIN:{component}")));
let closes = lines
.next_back()
.is_some_and(|line| line.eq_ignore_ascii_case(&format!("END:{component}")));
opens && closes
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_ordinary_key_is_its_own_hint_and_mints_nothing() {
let link = PimdirLinkId::from("a@example.org");
assert_eq!(
Kind::Mail.split_link_id(&link),
LinkId {
hint: Some("a@example.org"),
mint: None,
},
);
}
#[test]
fn a_kind_fallback_offers_no_hint() {
let link = PimdirLinkId::from("alt:subject|date|from");
assert_eq!(Kind::Mail.split_link_id(&link), LinkId::default());
}
#[test]
fn a_minted_key_splits_into_the_shared_identity_and_the_copys_own_part() {
let link = PimdirLinkId::from("dup:a@example.org#146");
assert_eq!(
Kind::Mail.split_link_id(&link),
LinkId {
hint: Some("a@example.org"),
mint: Some("146"),
},
);
}
#[test]
fn a_hint_carrying_the_separator_survives_the_split() {
let link = PimdirLinkId::from("dup:a#b@example.org#146");
assert_eq!(
Kind::Mail.split_link_id(&link),
LinkId {
hint: Some("a#b@example.org"),
mint: Some("146"),
},
);
}
#[test]
#[cfg(feature = "dav")]
fn a_mint_over_a_fallback_keeps_the_mint_and_no_hint() {
let link = PimdirLinkId::from("dup:hash:cbf29ce484222325#card-2.vcf");
assert_eq!(
Kind::Vcard.split_link_id(&link),
LinkId {
hint: None,
mint: Some("card-2.vcf"),
},
);
}
#[test]
#[cfg(feature = "dav")]
fn a_minted_calendar_key_names_the_href_it_came_from() {
let link = PimdirLinkId::from("dup:event-1@google.com#event-1%2540google.com.ics");
assert_eq!(
Kind::Ical.split_link_id(&link),
LinkId {
hint: Some("event-1@google.com"),
mint: Some("event-1%2540google.com.ics"),
},
);
}
#[test]
fn a_key_the_engine_mints_splits_back_into_its_parts() {
let minted = PimdirLinkId::from("a@example.org").minted(&"146".into());
assert_eq!(
Kind::Mail.split_link_id(&minted),
LinkId {
hint: Some("a@example.org"),
mint: Some("146"),
},
);
}
#[test]
#[cfg(feature = "dav")]
fn a_settled_body_is_read_as_the_kind_and_the_item_it_claims() {
let bound = PimdirLinkId::from("uid:a");
let card = b"BEGIN:VCARD\r\nVERSION:4.0\r\nUID:uid:a\r\nFN:Jane\r\nEND:VCARD\r\n";
Kind::Vcard.validate_body(card, &bound).unwrap();
let err = Kind::Vcard
.validate_body(b"not a card", &bound)
.unwrap_err()
.to_string();
assert!(err.contains("BEGIN:VCARD"), "{err}");
let renamed = b"BEGIN:VCARD\r\nVERSION:4.0\r\nUID:uid:b\r\nEND:VCARD\r\n";
let err = Kind::Vcard
.validate_body(renamed, &bound)
.unwrap_err()
.to_string();
assert!(err.contains("uid:b"), "{err}");
assert!(Kind::Mail.validate_body(card, &bound).is_err());
}
}