use std::io::Write;
use anyhow::{Result, anyhow, bail};
use chrono::DateTime;
use io_pimdir::{
client::{
blobs::PimdirBlobWriter,
producer::PimdirPendingAction,
reader::{PimdirCollection, PimdirItem},
},
codec::PimdirAction,
collection::PimdirCollectionId,
object::{PimdirHash, PimdirObject},
placement::PimdirFlags,
summary::{
PimdirAddress, PimdirSummary,
mail::{self, PimdirMailSummary},
},
};
use log::warn;
use serde::Serialize;
use crate::{
email::{
address::Address,
envelope::Envelope,
flag::{Flag, FlagOp, IanaFlag},
mailbox::Mailbox,
search::{eval, query::SearchEmailsQuery},
submission::SubmissionEnvelope,
},
pimdir::client::PimdirClient,
};
const MAIL_KIND: &str = "message/rfc822";
const SUBMIT: &str = "submit";
const SCAN_BATCH: usize = 500;
pub(crate) fn is_mail(kind: &str) -> bool {
kind.split(';').next().unwrap_or_default().trim() == MAIL_KIND
}
impl PimdirClient {
pub(crate) fn hub_id(&self, mailbox: &str) -> Result<String> {
let mut ids: Vec<String> = self
.mail_collections()?
.into_iter()
.map(|collection| collection.id)
.collect();
if ids.iter().any(|id| id == mailbox) {
return Ok(mailbox.to_string());
}
ids.sort();
bail!(
"Mailbox `{mailbox}` not found in the pimdir store, which holds: {}",
ids.join(", "),
)
}
fn mail_collections(&self) -> Result<Vec<PimdirCollection>> {
Ok(self
.store
.list_collections_by_account(self.account.as_deref())
.map_err(|err| anyhow!("List pimdir collections: {err}"))?
.into_iter()
.filter(|collection| is_mail(&collection.kind))
.collect())
}
pub fn list_mailboxes(&mut self, with_counts: bool) -> Result<Vec<Mailbox>> {
let mut mailboxes = Vec::new();
for collection in self.mail_collections()? {
let total = if with_counts {
Some(
self.store
.count_items(&collection.id)
.map_err(|err| anyhow!("Count items in `{}`: {err}", collection.id))?,
)
} else {
None
};
mailboxes.push(Mailbox {
id: collection.id,
name: collection.name,
role: None,
total,
unread: None,
});
}
mailboxes.sort_by(|a, b| a.id.cmp(&b.id));
Ok(mailboxes)
}
pub fn list_envelopes(
&mut self,
mailbox: &str,
page: Option<u32>,
page_size: Option<u32>,
_with_attachment: bool,
) -> Result<Vec<Envelope>> {
let collection = self.hub_id(mailbox)?;
let envelopes: Vec<Envelope> = self
.scan_items(&collection)?
.iter()
.map(envelope_from_item)
.collect();
Ok(paginate(envelopes, page, page_size))
}
pub fn search_envelopes(
&mut self,
mailbox: &str,
query: Option<&SearchEmailsQuery>,
page: Option<u32>,
page_size: Option<u32>,
_with_attachment: bool,
) -> Result<Vec<Envelope>> {
let collection = self.hub_id(mailbox)?;
let filter = query.and_then(|q| q.filter.as_ref());
let mut hits: Vec<Envelope> = self
.scan_items(&collection)?
.iter()
.map(envelope_from_item)
.filter(|envelope| match filter {
Some(filter) => eval::matches_filter(envelope, &[], filter),
None => true,
})
.collect();
eval::sort_envelopes(&mut hits, query.and_then(|q| q.sort.as_deref()));
Ok(paginate(hits, page, page_size))
}
pub fn queued_messages(&mut self, mailbox: &str) -> Result<usize> {
Ok(self.queued_mail(mailbox)?.len())
}
pub fn queued_envelopes(&mut self, mailbox: &str) -> Result<Vec<PimdirQueued>> {
let queued = self.queued_mail(mailbox)?;
let mut rows = Vec::new();
for action in &queued {
let body = match action.action.object_hash() {
Some(hash) => self.blobs.get(hash)?,
None => None,
};
rows.extend(queued_from_action(action, body.as_deref()));
}
Ok(rows)
}
pub fn get_message(&mut self, mailbox: &str, id: &str, seen: bool) -> Result<Vec<u8>> {
let collection = self.hub_id(mailbox)?;
let Some(item) = self.get(&collection, id)? else {
bail!("Message `{id}` not found in `{mailbox}`");
};
let Some(hash) = item.object else {
bail!(
"Message `{id}` in `{mailbox}` is not downloaded yet (body not fetched); \
run a sync to hydrate it"
);
};
let bytes = self
.blobs
.get(&hash)?
.ok_or_else(|| anyhow!("Body blob missing for `{id}` in `{mailbox}`"))?;
if seen {
let seen_flag = Flag::from_iana(IanaFlag::Seen);
if let Err(err) = self.store_flags(mailbox, &[id], &[seen_flag], FlagOp::Add) {
warn!("could not stage \\Seen on `{id}` in `{mailbox}`: {err:#}");
}
}
Ok(bytes)
}
pub fn store_flags(
&mut self,
mailbox: &str,
ids: &[&str],
flags: &[Flag],
op: FlagOp,
) -> Result<()> {
let collection = self.hub_id(mailbox)?;
let mut producer = self.producer()?;
for id in ids {
let seq = self.seq(&collection, id)?;
let current = self
.get(&collection, id)?
.map(|item| item.flags)
.unwrap_or(PimdirFlags::Unknown);
let action = PimdirAction::SetFlags {
seq,
flags: apply_flag_op(¤t, flags, op),
};
producer
.enqueue(&collection, &action, None)
.map_err(|err| anyhow!("Stage flags on `{id}` in `{mailbox}`: {err}"))?;
}
Ok(())
}
pub fn add_message(&mut self, mailbox: &str, flags: &[Flag], raw: Vec<u8>) -> Result<String> {
let collection = self.hub_id(mailbox)?;
let link_id = mail::derive(&raw).link_id;
let hash = self.blobs.hash(&raw);
let writer = self.blobs.writer()?;
let size = write_blob(writer, &raw, &hash)?;
let object = PimdirObject {
hash,
size: size as usize,
};
let mut producer = self.producer()?;
let action = PimdirAction::Add {
link_id: Some(link_id.clone()),
flags: flags.iter().map(Flag::raw).collect(),
object: Some(object.hash.clone()),
};
producer
.enqueue(&collection, &action, Some(&object))
.map_err(|err| anyhow!("Stage add in `{mailbox}`: {err}"))?;
Ok(link_id.0)
}
pub fn send_message(&mut self, mailbox: Option<&str>, raw: Vec<u8>) -> Result<i64> {
let Some(mailbox) = mailbox else {
bail!(
"A pimdir account queues a sent message under a mailbox: \
set `mailbox.alias.sent`, or pass one with `--save`"
);
};
let collection = self.hub_id(mailbox)?;
let envelope = SubmissionEnvelope::parse(&raw)?;
let hash = self.blobs.hash(&raw);
let writer = self.blobs.writer()?;
let size = write_blob(writer, &raw, &hash)?;
let object = PimdirObject {
hash,
size: size as usize,
};
let payload = SubmitPayload {
v: 1,
object: &object.hash.0,
from: &envelope.from,
rcpts: &envelope.rcpts,
subject: envelope.subject.as_deref(),
};
let action = PimdirAction::Unknown {
kind: SUBMIT.to_owned(),
payload: serde_json::to_string(&payload)?,
object_hash: Some(object.hash.clone()),
};
let mut producer = self.producer()?;
producer
.enqueue(&collection, &action, Some(&object))
.map_err(|err| anyhow!("Queue send in `{mailbox}`: {err}"))
}
pub fn copy_messages(&mut self, from: &str, to: &str, ids: &[&str]) -> Result<usize> {
self.refile(from, to, ids, |seq, target| PimdirAction::Copy {
seq,
to: target,
})
}
pub fn move_messages(&mut self, from: &str, to: &str, ids: &[&str]) -> Result<usize> {
self.refile(from, to, ids, |seq, target| PimdirAction::Move {
seq,
to: target,
})
}
pub fn delete_messages(&mut self, mailbox: &str, ids: &[&str]) -> Result<()> {
let collection = self.hub_id(mailbox)?;
let mut producer = self.producer()?;
for id in ids {
let seq = self.seq(&collection, id)?;
producer
.enqueue(&collection, &PimdirAction::Remove { seq }, None)
.map_err(|err| anyhow!("Stage delete of `{id}` in `{mailbox}`: {err}"))?;
}
Ok(())
}
fn refile(
&mut self,
from: &str,
to: &str,
ids: &[&str],
build: fn(i64, PimdirCollectionId) -> PimdirAction,
) -> Result<usize> {
let source = self.hub_id(from)?;
let target = PimdirCollectionId(self.hub_id(to)?);
let mut producer = self.producer()?;
for id in ids {
let seq = self.seq(&source, id)?;
producer
.enqueue(&source, &build(seq, target.clone()), None)
.map_err(|err| anyhow!("Stage refile of `{id}` from `{from}` to `{to}`: {err}"))?;
}
Ok(ids.len())
}
fn get(&self, collection: &str, id: &str) -> Result<Option<PimdirItem>> {
self.store
.get_item(collection, parse_id(id)?)
.map_err(|err| anyhow!("Read `{id}` in `{collection}`: {err}"))
}
fn seq(&self, collection: &str, id: &str) -> Result<i64> {
match self.get(collection, id)? {
Some(item) => Ok(item.seq),
None => bail!("Message `{id}` not found in `{}`", collection),
}
}
fn scan_items(&self, collection: &str) -> Result<Vec<PimdirItem>> {
let mut all: Vec<PimdirItem> = Vec::new();
loop {
let after = all.last().map(|item| (item.sort_key.as_str(), item.seq));
let page = self
.store
.list_summaries(collection, after, SCAN_BATCH)
.map_err(|err| anyhow!("List items in `{collection}`: {err}"))?;
let n = page.len();
all.extend(page);
if n < SCAN_BATCH {
break;
}
}
Ok(all)
}
fn queued_mail(&self, mailbox: &str) -> Result<Vec<PimdirPendingAction>> {
let collection = self.hub_id(mailbox)?;
let pending = self
.store
.pending_actions(&collection)
.map_err(|err| anyhow!("List queued messages in `{mailbox}`: {err}"))?;
Ok(pending
.into_iter()
.filter(|queued| is_queued_mail(&queued.action))
.collect())
}
}
#[derive(Serialize)]
struct SubmitPayload<'a> {
v: u8,
object: &'a str,
from: &'a str,
rcpts: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
subject: Option<&'a str>,
}
fn is_queued_mail(action: &PimdirAction) -> bool {
match action {
PimdirAction::Add { .. } => true,
PimdirAction::Unknown { kind, .. } => kind == SUBMIT,
_ => false,
}
}
fn envelope_from_item(item: &PimdirItem) -> Envelope {
envelope(
item.seq.to_string(),
&item.flags,
mail_summary(item.summary.as_ref()),
)
}
fn mail_summary(summary: Option<&PimdirSummary>) -> Option<&PimdirMailSummary> {
match summary {
Some(PimdirSummary::Mail(mail)) => Some(mail),
_ => None,
}
}
fn envelope(id: String, flags: &PimdirFlags, summary: Option<&PimdirMailSummary>) -> Envelope {
let flags = flags
.known()
.map(|flags| {
flags
.iter()
.map(|raw| Flag::from_raw(raw.clone()))
.collect()
})
.unwrap_or_default();
let mail = summary.cloned().unwrap_or_default();
Envelope {
id,
message_id: mail.message_id,
in_reply_to: mail.in_reply_to,
flags,
subject: mail.subject,
from: mail.from.into_iter().map(address).collect(),
to: mail.to.into_iter().map(address).collect(),
date: mail
.date
.as_deref()
.and_then(|date| DateTime::parse_from_rfc3339(date).ok()),
size: mail.size.unwrap_or(0),
has_attachment: mail.attachment,
}
}
fn address(address: PimdirAddress) -> Address {
Address {
name: address.name,
email: address.address,
}
}
#[derive(Clone, Debug)]
pub struct PimdirQueued {
pub id: i64,
pub created_at: String,
pub producer: String,
pub send: bool,
pub envelope: Envelope,
}
fn queued_from_action(queued: &PimdirPendingAction, body: Option<&[u8]>) -> Option<PimdirQueued> {
let seen: PimdirFlags = [Flag::from_iana(IanaFlag::Seen)]
.iter()
.map(Flag::raw)
.collect();
let (send, flags) = match &queued.action {
PimdirAction::Add { flags, .. } => (false, flags),
action if is_queued_mail(action) => (true, &seen),
_ => return None,
};
let summary = body.map(mail::derive).and_then(|derived| derived.summary);
let envelope = envelope(String::new(), flags, mail_summary(summary.as_ref()));
Some(PimdirQueued {
id: queued.id,
created_at: queued.created_at.clone(),
producer: queued.producer.clone(),
send,
envelope,
})
}
fn write_blob(mut writer: PimdirBlobWriter, raw: &[u8], hash: &PimdirHash) -> Result<u64> {
writer.write_all(raw)?;
Ok(writer.commit(hash)?)
}
fn apply_flag_op(current: &PimdirFlags, flags: &[Flag], op: FlagOp) -> PimdirFlags {
let incoming = flags.iter().map(|flag| flag.raw().to_string());
match op {
FlagOp::Set => incoming.collect(),
FlagOp::Add => {
let mut set = current.known().cloned().unwrap_or_default();
set.extend(incoming);
PimdirFlags::Known(set)
}
FlagOp::Remove => {
let mut set = current.known().cloned().unwrap_or_default();
for flag in incoming {
set.remove(&flag);
}
PimdirFlags::Known(set)
}
}
}
fn parse_id(id: &str) -> Result<i64> {
id.parse::<i64>()
.map_err(|_| anyhow!("Invalid message id `{id}` (expected a number)"))
}
fn paginate<T>(items: Vec<T>, page: Option<u32>, page_size: Option<u32>) -> Vec<T> {
let Some(size) = page_size else {
return items;
};
if size == 0 {
return Vec::new();
}
let page = page.unwrap_or(1).max(1);
let skip = ((page - 1) as usize).saturating_mul(size as usize);
if skip >= items.len() {
return Vec::new();
}
items.into_iter().skip(skip).take(size as usize).collect()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use io_pimdir::placement::{PimdirLevel, PimdirLinkId};
use super::*;
fn item(seq: i64, link_id: &str, flags: PimdirFlags, mail: PimdirMailSummary) -> PimdirItem {
PimdirItem {
seq,
link_id: PimdirLinkId(link_id.into()),
flags,
sort_key: String::new(),
object: None,
level: PimdirLevel::Meta,
summary: Some(PimdirSummary::Mail(mail)),
retention: None,
}
}
fn known(flags: &[&str]) -> PimdirFlags {
PimdirFlags::from_iter(flags.iter().copied())
}
fn person(address: &str, name: Option<&str>) -> PimdirAddress {
PimdirAddress {
address: address.into(),
name: name.map(String::from),
}
}
#[test]
fn envelope_is_built_from_the_summary_without_a_body() {
let item = item(
42,
"x@y",
known(&["\\Seen"]),
PimdirMailSummary {
message_id: Some("x@y".into()),
in_reply_to: vec!["parent@y".into()],
subject: "Hi".into(),
date: Some("2026-08-01T10:00:00Z".into()),
size: Some(99),
attachment: Some(true),
from: vec![person("a@x.org", Some("Alice"))],
to: vec![person("b@x.org", None)],
..Default::default()
},
);
let envelope = envelope_from_item(&item);
assert_eq!(envelope.id, "42");
assert_eq!(envelope.message_id.as_deref(), Some("x@y"));
assert_eq!(envelope.in_reply_to, ["parent@y"]);
assert_eq!(envelope.subject, "Hi");
assert_eq!(envelope.from[0].email, "a@x.org");
assert_eq!(envelope.from[0].name.as_deref(), Some("Alice"));
assert_eq!(envelope.to[0].email, "b@x.org");
assert_eq!(envelope.to[0].name, None);
assert_eq!(
envelope.date.map(|date| date.to_rfc3339()).as_deref(),
Some("2026-08-01T10:00:00+00:00")
);
assert_eq!(envelope.size, 99);
assert_eq!(envelope.has_attachment, Some(true));
assert!(envelope.flags.iter().any(|f| f.raw() == "\\Seen"));
}
#[test]
fn two_items_sharing_a_message_id_project_two_public_ids() {
let twice = || PimdirMailSummary {
message_id: Some("twice@host".into()),
subject: "Twice".into(),
..Default::default()
};
let bare = envelope_from_item(&item(11, "twice@host", known(&[]), twice()));
let minted = envelope_from_item(&item(12, "dup:twice@host#1174", known(&[]), twice()));
assert_eq!(bare.message_id.as_deref(), Some("twice@host"));
assert_eq!(bare.message_id, minted.message_id);
assert_eq!(bare.id, "11");
assert_eq!(minted.id, "12");
assert!(!minted.id.contains("dup:"), "got {}", minted.id);
}
#[test]
fn an_unread_flag_set_renders_as_no_flags_rather_than_panicking() {
let item = PimdirItem {
seq: 1,
link_id: PimdirLinkId("x@y".into()),
flags: PimdirFlags::Unknown,
sort_key: String::new(),
object: None,
level: PimdirLevel::Probed,
summary: None,
retention: None,
};
let envelope = envelope_from_item(&item);
assert!(envelope.flags.is_empty());
assert_eq!(envelope.id, "1");
assert_eq!(envelope.subject, "");
}
#[test]
fn flag_ops_add_set_and_remove() {
let base = known(&["\\Seen"]);
let flagged = [Flag::from_raw("\\Flagged")];
let added = apply_flag_op(&base, &flagged, FlagOp::Add);
assert!(added.contains("\\Seen") && added.contains("\\Flagged"));
let set = apply_flag_op(&base, &flagged, FlagOp::Set);
assert!(!set.contains("\\Seen") && set.contains("\\Flagged"));
let seen = [Flag::from_raw("\\Seen")];
let removed = apply_flag_op(&base, &seen, FlagOp::Remove);
assert!(!removed.contains("\\Seen"));
}
#[test]
fn a_flag_op_on_an_unknown_set_stages_a_known_one() {
let staged = apply_flag_op(
&PimdirFlags::Unknown,
&[Flag::from_raw("\\Seen")],
FlagOp::Add,
);
assert_eq!(staged.known().map(BTreeSet::len), Some(1));
assert!(staged.contains("\\Seen"));
}
#[test]
fn a_queued_creation_renders_as_mail_with_no_id() {
let queued = PimdirPendingAction {
id: 7,
created_at: "2026-08-27T10:00:00Z".into(),
producer: "himalaya".into(),
collection: "INBOX".into(),
action: PimdirAction::Add {
link_id: Some(PimdirLinkId("draft@x.org".into())),
flags: known(&["\\Draft"]),
object: Some(PimdirHash("cafe".into())),
},
attempts: 0,
};
let body = b"Message-ID: <draft@x.org>\r\nSubject: Re: lunch\r\nTo: Alice <alice@x.org>\r\n\r\nbody";
let queued = queued_from_action(&queued, Some(body)).unwrap();
assert_eq!(queued.id, 7);
assert_eq!(queued.created_at, "2026-08-27T10:00:00Z");
assert_eq!(queued.envelope.subject, "Re: lunch");
assert_eq!(queued.envelope.to[0].email, "alice@x.org");
assert_eq!(queued.envelope.message_id.as_deref(), Some("draft@x.org"));
assert!(queued.envelope.flags.iter().any(|f| f.raw() == "\\Draft"));
assert!(queued.envelope.id.is_empty());
}
#[test]
fn only_a_queued_creation_renders_as_mail() {
let queued = PimdirPendingAction {
id: 8,
created_at: "2026-08-27T10:00:00Z".into(),
producer: "himalaya".into(),
collection: "INBOX".into(),
action: PimdirAction::Remove { seq: 42 },
attempts: 0,
};
assert!(queued_from_action(&queued, None).is_none());
}
fn sent_store() -> (tempfile::TempDir, PimdirClient) {
let dir = tempfile::tempdir().unwrap();
let store = io_pimdir::client::PimdirStore::open(dir.path()).unwrap();
store.ensure_collection("imap/Sent", MAIL_KIND).unwrap();
drop(store);
let config = crate::config::PimdirConfig {
root: dir.path().to_path_buf(),
account: None,
};
(dir, PimdirClient::new(config).unwrap())
}
const RAW: &[u8] = b"From: a@x.org\r\nTo: b@y.org\r\nBcc: c@y.org\r\nSubject: hi\r\n\r\nhello";
#[test]
fn a_sent_message_is_one_submit_row_with_its_envelope() {
let (_dir, mut client) = sent_store();
let row = client
.send_message(Some("imap/Sent"), RAW.to_vec())
.unwrap();
let pending = client.store.pending_actions("imap/Sent").unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, row);
let PimdirAction::Unknown {
kind,
payload,
object_hash,
} = &pending[0].action
else {
panic!("expected a submit intent, got {:?}", pending[0].action);
};
assert_eq!(kind, SUBMIT);
let hash = object_hash.as_ref().unwrap();
let payload: serde_json::Value = serde_json::from_str(payload).unwrap();
assert_eq!(
payload,
serde_json::json!({
"v": 1,
"object": hash.0,
"from": "a@x.org",
"rcpts": ["b@y.org", "c@y.org"],
"subject": "hi",
})
);
assert_eq!(client.blobs.get(hash).unwrap().as_deref(), Some(RAW));
let queued = client.queued_envelopes("imap/Sent").unwrap();
assert_eq!(queued.len(), 1);
assert!(queued[0].send);
assert_eq!(queued[0].envelope.subject, "hi");
assert_eq!(client.queued_messages("imap/Sent").unwrap(), 1);
}
#[test]
fn a_send_with_no_mailbox_stages_nothing() {
let (_dir, mut client) = sent_store();
let err = client.send_message(None, RAW.to_vec()).unwrap_err();
assert!(err.to_string().contains("mailbox.alias.sent"));
assert!(
client
.store
.pending_actions("imap/Sent")
.unwrap()
.is_empty()
);
}
#[test]
fn a_send_with_no_recipient_stages_nothing() {
let (_dir, mut client) = sent_store();
let raw = b"From: a@x.org\r\nSubject: hi\r\n\r\nhello".to_vec();
assert!(client.send_message(Some("imap/Sent"), raw).is_err());
assert!(
client
.store
.pending_actions("imap/Sent")
.unwrap()
.is_empty()
);
}
}