Initial commit
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use mail_parser::{Address, HeaderName, HeaderValue, MessageParser};
|
||||
|
||||
use crate::sync::keys::{EmailIndex, email_index};
|
||||
|
||||
fn addrs(a: Option<&Address>) -> Vec<String> {
|
||||
match a {
|
||||
Some(addr) => addr
|
||||
.iter()
|
||||
.filter_map(|x| x.address().map(|s| s.to_owned()))
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn message_ids(value: &HeaderValue) -> Vec<String> {
|
||||
match value {
|
||||
HeaderValue::Text(s) => vec![s.to_string()],
|
||||
HeaderValue::TextList(l) => l.iter().map(|s| s.to_string()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn email_index_from_blob(bytes: &[u8]) -> EmailIndex {
|
||||
email_meta_from_blob(bytes).0
|
||||
}
|
||||
|
||||
pub fn email_meta_from_blob(bytes: &[u8]) -> (EmailIndex, Option<String>) {
|
||||
let Some(m) = MessageParser::default().parse(bytes) else {
|
||||
return (email_index(&[], &[], "", "", &[]), None);
|
||||
};
|
||||
let mut mids: Vec<String> = Vec::new();
|
||||
for v in m.header_values(HeaderName::MessageId) {
|
||||
mids.extend(message_ids(v));
|
||||
}
|
||||
if mids.is_empty()
|
||||
&& let Some(single) = m.message_id()
|
||||
{
|
||||
mids.push(single.to_owned());
|
||||
}
|
||||
let date = m.date().map(|d| d.to_rfc3339());
|
||||
let idx = email_index(
|
||||
&mids,
|
||||
&addrs(m.from()),
|
||||
m.subject().unwrap_or(""),
|
||||
date.as_deref().unwrap_or(""),
|
||||
&addrs(m.to()),
|
||||
);
|
||||
(idx, date)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sync::keys::{EmailKey, email_key};
|
||||
|
||||
#[test]
|
||||
fn parses_core_fields_and_keys_by_message_id() {
|
||||
let raw = b"From: Alice <[email protected]>\r\nTo: Bob <[email protected]>\r\nSubject: Hi there\r\nMessage-ID: <abc-1@host>\r\nDate: Mon, 12 May 2025 10:00:00 +0200\r\n\r\nbody\r\n";
|
||||
let idx = email_index_from_blob(raw);
|
||||
assert_eq!(idx.mids, vec!["abc-1@host".to_owned()]);
|
||||
assert_eq!(
|
||||
email_key(&idx),
|
||||
EmailKey::MessageId("abc-1@host".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_at_offset_is_preserved_then_z_for_utc() {
|
||||
let off = email_index_from_blob(
|
||||
b"Message-ID: <a@h>\r\nDate: Mon, 12 May 2025 10:00:00 +0200\r\n\r\nx",
|
||||
);
|
||||
let utc = email_index_from_blob(
|
||||
b"Message-ID: <b@h>\r\nDate: Mon, 12 May 2025 08:00:00 +0000\r\n\r\nx",
|
||||
);
|
||||
|
||||
assert_ne!(off.fb, utc.fb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_message_id_falls_back_to_hash() {
|
||||
let idx = email_index_from_blob(b"From: a@x\r\nSubject: only\r\n\r\nbody");
|
||||
assert!(idx.mids.is_empty());
|
||||
assert!(matches!(email_key(&idx), EmailKey::Fallback(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_message_id_values_are_all_captured() {
|
||||
let idx =
|
||||
email_index_from_blob(b"Message-ID: <a@h>\r\nMessage-ID: <b@h>\r\nSubject: s\r\n\r\nx");
|
||||
assert_eq!(idx.mids, vec!["a@h".to_owned(), "b@h".to_owned()]);
|
||||
assert_eq!(
|
||||
email_key(&idx),
|
||||
EmailKey::MessageId("a@h\u{1f}b@h".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_message_different_received_metadata_same_key() {
|
||||
let a = email_index_from_blob(
|
||||
b"From: a@x\r\nSubject: S\r\nMessage-ID: <dup@h>\r\nDate: Mon, 12 May 2025 10:00:00 +0200\r\n\r\nbody",
|
||||
);
|
||||
let b = email_index_from_blob(
|
||||
b"From: a@x\r\nSubject: S\r\nMessage-ID: <DUP@h>\r\nDate: Tue, 13 May 2025 11:00:00 +0000\r\n\r\nbody",
|
||||
);
|
||||
assert_eq!(email_key(&a), email_key(&b));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{IsTerminal, Write};
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::db;
|
||||
use crate::error::Error;
|
||||
use crate::jmap::blobxfer;
|
||||
use crate::jmap::connect::{self, Connected};
|
||||
use crate::jmap::error::JmapError;
|
||||
use crate::jmap::http::HttpClient;
|
||||
use crate::jmap::request::{Request, SetRequest, get_all, get_objects, query_all_ids, set_call};
|
||||
use crate::jmap::session::{Limits, Session};
|
||||
use crate::jmap::wire::JmapId;
|
||||
use crate::logging::{LEVEL_DEFAULT, Logger};
|
||||
use crate::sync::import_jmap::mapping::{BlobUpload, TargetResolver};
|
||||
use crate::sync::{CommonConfig, Context, ExportConfig, Summary, TypeCounts};
|
||||
use crate::types::ObjectType;
|
||||
|
||||
const EXPORT_ORDER: [ObjectType; 10] = [
|
||||
ObjectType::Mailbox,
|
||||
ObjectType::AddressBook,
|
||||
ObjectType::Calendar,
|
||||
ObjectType::FileNode,
|
||||
ObjectType::Identity,
|
||||
ObjectType::SieveScript,
|
||||
ObjectType::ParticipantIdentity,
|
||||
ObjectType::Email,
|
||||
ObjectType::ContactCard,
|
||||
ObjectType::CalendarEvent,
|
||||
];
|
||||
|
||||
type IdMap = HashMap<i64, JmapId>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Maps {
|
||||
m: HashMap<ObjectType, IdMap>,
|
||||
}
|
||||
|
||||
impl Maps {
|
||||
fn insert(&mut self, ty: ObjectType, local: i64, target: JmapId) {
|
||||
self.m.entry(ty).or_default().insert(local, target);
|
||||
}
|
||||
}
|
||||
|
||||
impl TargetResolver for Maps {
|
||||
fn target(&self, ty: ObjectType, local_id: i64) -> Option<JmapId> {
|
||||
self.m.get(&ty)?.get(&local_id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
struct Uploader<'a> {
|
||||
net: &'a Net,
|
||||
conn: &'a Connection,
|
||||
cache: HashMap<i64, JmapId>,
|
||||
}
|
||||
|
||||
impl<'a> Uploader<'a> {
|
||||
fn new(net: &'a Net, conn: &'a Connection) -> Uploader<'a> {
|
||||
Uploader {
|
||||
net,
|
||||
conn,
|
||||
cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_with(&mut self, local_id: i64, content_type: &str) -> Result<JmapId, JmapError> {
|
||||
if let Some(id) = self.cache.get(&local_id) {
|
||||
return Ok(id.clone());
|
||||
}
|
||||
let id = if self.net.dry_run {
|
||||
let _exists = db::blobs::blob_bytes(self.conn, local_id)?
|
||||
.ok_or_else(|| JmapError::malformed(format!("blob local id {local_id} missing")))?;
|
||||
JmapId(format!("dryrun-blob-{local_id}"))
|
||||
} else {
|
||||
let bytes = db::blobs::blob_bytes(self.conn, local_id)?
|
||||
.ok_or_else(|| JmapError::malformed(format!("blob local id {local_id} missing")))?;
|
||||
blobxfer::upload_bytes(
|
||||
&self.net.client,
|
||||
&self.net.session,
|
||||
&self.net.account,
|
||||
content_type,
|
||||
&bytes,
|
||||
)?
|
||||
};
|
||||
self.cache.insert(local_id, id.clone());
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl BlobUpload for Uploader<'_> {
|
||||
fn upload(&mut self, local_id: i64) -> Result<JmapId, JmapError> {
|
||||
self.upload_with(local_id, "application/octet-stream")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Net {
|
||||
client: HttpClient,
|
||||
api: String,
|
||||
account: String,
|
||||
limits: Limits,
|
||||
session: Session,
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
fn has_rows(conn: &Connection, ty: ObjectType) -> bool {
|
||||
let table = crate::sync::table_name(ty);
|
||||
conn.query_row(&format!("SELECT EXISTS(SELECT 1 FROM {table})"), [], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.map(|n| n != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn run(common: CommonConfig, config: ExportConfig) -> Result<Summary, Error> {
|
||||
let logger = common.logger;
|
||||
let ctx = Context::open(common, &config.connect)?;
|
||||
let connected = connect::prepare(&ctx, &config.connect)?;
|
||||
|
||||
let net = Net {
|
||||
client: ctx.client.clone(),
|
||||
api: connected.session.api_url.clone(),
|
||||
account: connected.account_id.clone(),
|
||||
limits: connected.limits,
|
||||
session: connected.session.clone(),
|
||||
dry_run: ctx.dry_run(),
|
||||
};
|
||||
|
||||
let work = work_list(&ctx.conn, &config, &connected, &logger);
|
||||
let mut maps = Maps::default();
|
||||
let mut summary = Summary::default();
|
||||
let mut dry_rows: Vec<(&'static str, u64, u64, u64)> = Vec::new();
|
||||
let mut plans: HashMap<ObjectType, Plan> = HashMap::new();
|
||||
let mut counts_per_type: HashMap<ObjectType, TypeCounts> = HashMap::new();
|
||||
|
||||
for ty in &work {
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!("export: {} ...", ty.jmap_name());
|
||||
}
|
||||
let mut counts = TypeCounts::default();
|
||||
let res = reconcile_type(
|
||||
&ctx,
|
||||
&net,
|
||||
*ty,
|
||||
&mut maps,
|
||||
&logger,
|
||||
&mut counts,
|
||||
&mut dry_rows,
|
||||
);
|
||||
let plan = match res {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
logger.warn(&format!("type {} aborted: {e}", ty.jmap_name()));
|
||||
counts.failed += 1;
|
||||
Plan::default()
|
||||
}
|
||||
};
|
||||
plans.insert(*ty, plan);
|
||||
counts_per_type.insert(*ty, counts);
|
||||
}
|
||||
|
||||
if config.prune {
|
||||
prune_phase(
|
||||
&ctx,
|
||||
&net,
|
||||
&work,
|
||||
&plans,
|
||||
&config,
|
||||
&logger,
|
||||
&mut counts_per_type,
|
||||
)?;
|
||||
}
|
||||
|
||||
for ty in &work {
|
||||
if let Some(counts) = counts_per_type.remove(ty) {
|
||||
summary.per_type.push((ty.jmap_name(), counts));
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.dry_run() {
|
||||
print_dry_run(&dry_rows, config.prune);
|
||||
return Ok(Summary::default());
|
||||
}
|
||||
summary.retries_observed = ctx.client.retries_observed();
|
||||
summary.retry_after_sleeps = ctx.client.retry_after_sleeps();
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn prune_phase(
|
||||
ctx: &Context,
|
||||
net: &Net,
|
||||
work: &[ObjectType],
|
||||
plans: &HashMap<ObjectType, Plan>,
|
||||
config: &ExportConfig,
|
||||
logger: &Logger,
|
||||
counts_per_type: &mut HashMap<ObjectType, TypeCounts>,
|
||||
) -> Result<(), Error> {
|
||||
let totals: Vec<(ObjectType, &Plan)> = work
|
||||
.iter()
|
||||
.filter_map(|ty| plans.get(ty).map(|p| (*ty, p)))
|
||||
.filter(|(_, p)| !p.prune_candidates.is_empty())
|
||||
.collect();
|
||||
if totals.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!("prune plan:");
|
||||
let total: usize = totals.iter().map(|(_, p)| p.prune_candidates.len()).sum();
|
||||
for (ty, p) in &totals {
|
||||
eprintln!(
|
||||
" {:<22} {:>6} candidate(s); sample: {}",
|
||||
ty.jmap_name(),
|
||||
p.prune_candidates.len(),
|
||||
sample(&p.prune_candidates),
|
||||
);
|
||||
}
|
||||
eprintln!(" {:<22} {:>6} total", "(all types)", total);
|
||||
if ctx.dry_run() {
|
||||
return Ok(());
|
||||
}
|
||||
if !config.yes && std::io::stdin().is_terminal() {
|
||||
eprint!("destroy all {total} objects across all types? [y/N] ");
|
||||
let _ = std::io::stderr().flush();
|
||||
let mut line = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut line)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
if !matches!(line.trim(), "y" | "Y" | "yes") {
|
||||
return Err(Error::PruneAborted);
|
||||
}
|
||||
}
|
||||
for ty in work.iter().rev() {
|
||||
if let Some(plan) = plans.get(ty)
|
||||
&& !plan.prune_candidates.is_empty()
|
||||
&& let Some(counts) = counts_per_type.get_mut(ty)
|
||||
{
|
||||
do_destroy(net, *ty, plan, logger, counts);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn work_list(
|
||||
conn: &Connection,
|
||||
config: &ExportConfig,
|
||||
connected: &Connected,
|
||||
logger: &Logger,
|
||||
) -> Vec<ObjectType> {
|
||||
let selected = config.objects.as_ref();
|
||||
EXPORT_ORDER
|
||||
.into_iter()
|
||||
.filter(|ty| selected.map(|s| s.contains(ty)).unwrap_or(true))
|
||||
.filter(|ty| has_rows(conn, *ty))
|
||||
.filter(|ty| {
|
||||
if connected.supports(*ty) {
|
||||
true
|
||||
} else {
|
||||
logger.warn(&format!(
|
||||
"target does not support {}; skipping",
|
||||
ty.jmap_name()
|
||||
));
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn reconcile_type(
|
||||
ctx: &Context,
|
||||
net: &Net,
|
||||
ty: ObjectType,
|
||||
maps: &mut Maps,
|
||||
logger: &Logger,
|
||||
counts: &mut TypeCounts,
|
||||
dry_rows: &mut Vec<(&'static str, u64, u64, u64)>,
|
||||
) -> Result<Plan, Error> {
|
||||
let plan = match ty {
|
||||
ObjectType::Mailbox | ObjectType::FileNode => {
|
||||
tree::reconcile(ctx, net, ty, maps, counts, logger)
|
||||
}
|
||||
ObjectType::AddressBook | ObjectType::Calendar => {
|
||||
flat::reconcile(ctx, net, ty, maps, counts, logger)
|
||||
}
|
||||
ObjectType::Identity => keyed::reconcile_identity(ctx, net, maps, counts, logger),
|
||||
ObjectType::ParticipantIdentity => {
|
||||
keyed::reconcile_participant(ctx, net, maps, counts, logger)
|
||||
}
|
||||
ObjectType::SieveScript => sieve::reconcile(ctx, net, maps, counts, logger),
|
||||
ObjectType::ContactCard | ObjectType::CalendarEvent => {
|
||||
uidtype::reconcile(ctx, net, ty, maps, counts, logger)
|
||||
}
|
||||
ObjectType::Email => email::reconcile(ctx, net, maps, counts, logger),
|
||||
}?;
|
||||
|
||||
if ctx.dry_run() {
|
||||
dry_rows.push((
|
||||
ty.jmap_name(),
|
||||
counts.created,
|
||||
counts.skipped,
|
||||
plan.prune_candidates.len() as u64,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Plan {
|
||||
pub prune_candidates: Vec<String>,
|
||||
pub active_sieve_target: Option<String>,
|
||||
}
|
||||
|
||||
fn do_destroy(net: &Net, ty: ObjectType, plan: &Plan, logger: &Logger, counts: &mut TypeCounts) {
|
||||
if ty == ObjectType::SieveScript {
|
||||
deactivate_active_sieve_script(net, logger);
|
||||
}
|
||||
let destroy = Value::Array(
|
||||
plan.prune_candidates
|
||||
.iter()
|
||||
.map(|s| Value::String(s.clone()))
|
||||
.collect(),
|
||||
);
|
||||
let extra = destroy_contents_arg(ty);
|
||||
match set_call(
|
||||
&net.client,
|
||||
&net.api,
|
||||
&net.account,
|
||||
ty.jmap_name(),
|
||||
SetRequest {
|
||||
destroy: Some(destroy),
|
||||
extra_args: &extra,
|
||||
..Default::default()
|
||||
},
|
||||
&net.limits,
|
||||
) {
|
||||
Ok(outcome) => {
|
||||
counts.deleted += outcome.destroyed.len() as u64;
|
||||
for (id, err) in &outcome.not_destroyed {
|
||||
logger.warn(&format!(
|
||||
"prune: {} {id} not destroyed: {err}",
|
||||
ty.jmap_name()
|
||||
));
|
||||
counts.skipped += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"prune {}: destroy request failed: {e}",
|
||||
ty.jmap_name()
|
||||
));
|
||||
counts.failed += plan.prune_candidates.len() as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deactivate_active_sieve_script(net: &Net, logger: &Logger) {
|
||||
let mut req = Request::new();
|
||||
req.call(
|
||||
"SieveScript/set",
|
||||
json!({ "accountId": net.account, "onSuccessDeactivateScript": true }),
|
||||
"d",
|
||||
);
|
||||
let outcome = req.send(&net.client, &net.api).and_then(|resp| {
|
||||
let mr = resp.first()?;
|
||||
crate::jmap::request::check_method_error(mr)
|
||||
});
|
||||
if let Err(e) = outcome {
|
||||
logger.warn(&format!(
|
||||
"prune: SieveScript deactivation failed before destroy: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy_contents_arg(ty: ObjectType) -> Vec<(&'static str, Value)> {
|
||||
match ty {
|
||||
ObjectType::AddressBook => vec![("onDestroyRemoveContents", Value::Bool(false))],
|
||||
ObjectType::Calendar => vec![("onDestroyRemoveEvents", Value::Bool(false))],
|
||||
ObjectType::FileNode => vec![("onDestroyRemoveChildren", Value::Bool(false))],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample(ids: &[String]) -> String {
|
||||
let n = ids.len().min(5);
|
||||
ids[..n].join(", ")
|
||||
}
|
||||
|
||||
fn print_dry_run(rows: &[(&'static str, u64, u64, u64)], prune: bool) {
|
||||
if prune {
|
||||
println!(
|
||||
"{:<22} {:>10} {:>10} {:>12}",
|
||||
"TYPE", "CREATE", "MATCHED", "WOULD-DESTROY"
|
||||
);
|
||||
for (ty, c, m, d) in rows {
|
||||
println!("{ty:<22} {c:>10} {m:>10} {d:>12}");
|
||||
}
|
||||
} else {
|
||||
println!("{:<22} {:>10} {:>10}", "TYPE", "CREATE", "MATCHED");
|
||||
for (ty, c, m, _) in rows {
|
||||
println!("{ty:<22} {c:>10} {m:>10}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod tree;
|
||||
|
||||
mod flat;
|
||||
|
||||
mod keyed;
|
||||
|
||||
mod sieve;
|
||||
|
||||
mod uidtype;
|
||||
|
||||
mod email;
|
||||
|
||||
mod common {
|
||||
use super::*;
|
||||
|
||||
pub fn target_query_get(
|
||||
net: &Net,
|
||||
ty: ObjectType,
|
||||
props: Option<&[&str]>,
|
||||
) -> Result<Vec<Value>, JmapError> {
|
||||
let ids = query_all_ids(
|
||||
&net.client,
|
||||
&net.api,
|
||||
&net.account,
|
||||
ty.jmap_name(),
|
||||
&net.limits,
|
||||
)?;
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let got = get_objects::<Value>(
|
||||
&net.client,
|
||||
&net.api,
|
||||
&net.account,
|
||||
ty.jmap_name(),
|
||||
&ids,
|
||||
props,
|
||||
&net.limits,
|
||||
)?;
|
||||
Ok(got.list)
|
||||
}
|
||||
|
||||
pub fn target_get_all(net: &Net, ty: ObjectType) -> Result<Vec<Value>, JmapError> {
|
||||
Ok(get_all::<Value>(&net.client, &net.api, &net.account, ty.jmap_name())?.list)
|
||||
}
|
||||
|
||||
pub fn jid(v: &Value) -> Option<String> {
|
||||
v.get("id").and_then(Value::as_str).map(str::to_owned)
|
||||
}
|
||||
|
||||
pub fn create_batch(
|
||||
net: &Net,
|
||||
ty: ObjectType,
|
||||
creates: Vec<(String, Value)>,
|
||||
) -> Result<crate::jmap::request::SetOutcome, JmapError> {
|
||||
if net.dry_run {
|
||||
return Ok(synthesize_dry_run_outcome(ty, &creates));
|
||||
}
|
||||
let mut map = Map::new();
|
||||
for (cid, obj) in creates {
|
||||
map.insert(cid, obj);
|
||||
}
|
||||
set_call(
|
||||
&net.client,
|
||||
&net.api,
|
||||
&net.account,
|
||||
ty.jmap_name(),
|
||||
SetRequest {
|
||||
create: Some(Value::Object(map)),
|
||||
..Default::default()
|
||||
},
|
||||
&net.limits,
|
||||
)
|
||||
}
|
||||
|
||||
fn synthesize_dry_run_outcome(
|
||||
ty: ObjectType,
|
||||
creates: &[(String, Value)],
|
||||
) -> crate::jmap::request::SetOutcome {
|
||||
let mut outcome = crate::jmap::request::SetOutcome::default();
|
||||
for (cid, _) in creates {
|
||||
let synthetic = serde_json::json!({
|
||||
"id": format!("dryrun-{}-{cid}", ty.jmap_name())
|
||||
});
|
||||
outcome.created.push((cid.clone(), synthetic));
|
||||
}
|
||||
outcome
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::common::{jid, target_query_get};
|
||||
use super::{Maps, Net, Plan, Uploader};
|
||||
use crate::error::Error;
|
||||
use crate::jmap::error::JmapError;
|
||||
use crate::jmap::request::{Request, check_method_error, get_objects};
|
||||
use crate::jmap::wire::JmapId;
|
||||
use crate::logging::Logger;
|
||||
use crate::sync::import_jmap::mapping::{EMAIL_SELECT, TargetResolver, row_to_email};
|
||||
use crate::sync::keys::{EmailIndex, EmailKey, email_index, email_keys, index_from_json};
|
||||
use crate::sync::{Context, TypeCounts};
|
||||
use crate::types::ObjectType;
|
||||
|
||||
fn server_index(v: &Value) -> EmailIndex {
|
||||
let arr = |k: &str| {
|
||||
v.get(k)
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.get("email").and_then(Value::as_str).map(str::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mids: Vec<String> = v
|
||||
.get("messageId")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
email_index(
|
||||
&mids,
|
||||
&arr("from"),
|
||||
v.get("subject").and_then(Value::as_str).unwrap_or(""),
|
||||
v.get("sentAt").and_then(Value::as_str).unwrap_or(""),
|
||||
&arr("to"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reconcile(
|
||||
ctx: &Context,
|
||||
net: &Net,
|
||||
maps: &mut Maps,
|
||||
counts: &mut TypeCounts,
|
||||
logger: &Logger,
|
||||
) -> Result<Plan, Error> {
|
||||
let ty = ObjectType::Email;
|
||||
|
||||
let target_min = target_query_get(net, ty, Some(&["messageId"])).map_err(Error::from)?;
|
||||
let mut indices: Vec<EmailIndex> = target_min.iter().map(server_index).collect();
|
||||
|
||||
let fallback_ids: Vec<JmapId> = target_min
|
||||
.iter()
|
||||
.zip(indices.iter())
|
||||
.filter(|(_, i)| i.mids.is_empty())
|
||||
.filter_map(|(v, _)| jid(v).map(JmapId))
|
||||
.collect();
|
||||
if !fallback_ids.is_empty() {
|
||||
let got = get_objects::<Value>(
|
||||
&net.client,
|
||||
&net.api,
|
||||
&net.account,
|
||||
ty.jmap_name(),
|
||||
&fallback_ids,
|
||||
Some(&["messageId", "from", "subject", "sentAt", "to"]),
|
||||
&net.limits,
|
||||
)
|
||||
.map_err(Error::from)?;
|
||||
let by_id: HashMap<String, &Value> = got
|
||||
.list
|
||||
.iter()
|
||||
.filter_map(|v| jid(v).map(|i| (i, v)))
|
||||
.collect();
|
||||
for (v, slot) in target_min.iter().zip(indices.iter_mut()) {
|
||||
if let Some(full) = jid(v).and_then(|i| by_id.get(&i)) {
|
||||
*slot = server_index(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
let target_keys: HashSet<EmailKey> = email_keys(&indices).into_iter().collect();
|
||||
|
||||
let local: Vec<(i64, crate::sync::import_jmap::mapping::EmailRow)> = {
|
||||
let mut stmt = ctx
|
||||
.conn
|
||||
.prepare(EMAIL_SELECT)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
stmt.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
Ok((id, row_to_email(row)))
|
||||
})
|
||||
.and_then(|m| m.collect::<Result<Vec<_>, _>>())
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
.into_iter()
|
||||
.map(|(id, r)| Ok((id, r.map_err(Error::from)?)))
|
||||
.collect::<Result<_, Error>>()?
|
||||
};
|
||||
|
||||
let local_indices: Vec<EmailIndex> = local
|
||||
.iter()
|
||||
.map(|(_, r)| index_from_json(&r.message_match))
|
||||
.collect();
|
||||
let local_keys = email_keys(&local_indices);
|
||||
|
||||
let mut uploader = Uploader::new(net, &ctx.conn);
|
||||
for (i, key) in local_keys.iter().enumerate() {
|
||||
if target_keys.contains(key) {
|
||||
counts.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let (local_id, row) = &local[i];
|
||||
let mut mids = Map::new();
|
||||
let mut all_resolved = true;
|
||||
for ml in &row.mailbox_locals {
|
||||
match maps.target(ObjectType::Mailbox, *ml) {
|
||||
Some(t) => {
|
||||
mids.insert(t.0, Value::Bool(true));
|
||||
}
|
||||
None => {
|
||||
all_resolved = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !all_resolved {
|
||||
logger.warn(&format!(
|
||||
"email local {local_id} skipped: mailbox not on target"
|
||||
));
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
let blob = match uploader.upload_with(row.blob_local_id, "message/rfc822") {
|
||||
Ok(b) => b.0,
|
||||
Err(e) => {
|
||||
logger.warn(&format!("email blob upload failed: {e}"));
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut kw = Map::new();
|
||||
for k in &row.keywords {
|
||||
kw.insert(k.clone(), Value::Bool(true));
|
||||
}
|
||||
let item = json!({
|
||||
"blobId": blob,
|
||||
"mailboxIds": Value::Object(mids),
|
||||
"keywords": Value::Object(kw),
|
||||
"receivedAt": row.received_at,
|
||||
});
|
||||
send_import_chunk(net, &[(format!("e{local_id}"), item)], counts, logger);
|
||||
}
|
||||
|
||||
Ok(Plan::default())
|
||||
}
|
||||
|
||||
fn send_import_chunk(
|
||||
net: &Net,
|
||||
items: &[(String, Value)],
|
||||
counts: &mut TypeCounts,
|
||||
logger: &Logger,
|
||||
) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
if net.dry_run {
|
||||
counts.created += items.len() as u64;
|
||||
return;
|
||||
}
|
||||
let mut map = Map::new();
|
||||
for (k, v) in items {
|
||||
map.insert(k.clone(), v.clone());
|
||||
}
|
||||
let mut req = Request::new();
|
||||
req.call(
|
||||
"Email/import",
|
||||
json!({ "accountId": net.account, "emails": Value::Object(map) }),
|
||||
"i",
|
||||
);
|
||||
if req.fits(&net.limits).is_err() {
|
||||
resplit_or_fail(net, items, counts, logger);
|
||||
return;
|
||||
}
|
||||
match req.send(&net.client, &net.api) {
|
||||
Ok(resp) => match resp.first().and_then(|mr| {
|
||||
check_method_error(mr)?;
|
||||
Ok(mr)
|
||||
}) {
|
||||
Ok(mr) => absorb_import(mr, counts, logger),
|
||||
Err(JmapError::RequestTooLarge) => resplit_or_fail(net, items, counts, logger),
|
||||
Err(JmapError::Method { error_type, .. }) if error_type == "requestTooLarge" => {
|
||||
resplit_or_fail(net, items, counts, logger);
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"Email/import method error ({} items): {e}",
|
||||
items.len()
|
||||
));
|
||||
counts.failed += items.len() as u64;
|
||||
}
|
||||
},
|
||||
Err(JmapError::RequestTooLarge) => resplit_or_fail(net, items, counts, logger),
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"Email/import send failed ({} items): {e}",
|
||||
items.len()
|
||||
));
|
||||
counts.failed += items.len() as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resplit_or_fail(net: &Net, items: &[(String, Value)], counts: &mut TypeCounts, logger: &Logger) {
|
||||
if items.len() <= 1 {
|
||||
for (cid, _) in items {
|
||||
logger.warn(&format!(
|
||||
"Email/import {cid} exceeds maxSizeRequest alone; skipped"
|
||||
));
|
||||
}
|
||||
counts.failed += items.len() as u64;
|
||||
return;
|
||||
}
|
||||
let mid = items.len() / 2;
|
||||
send_import_chunk(net, &items[..mid], counts, logger);
|
||||
send_import_chunk(net, &items[mid..], counts, logger);
|
||||
}
|
||||
|
||||
fn absorb_import(mr: &crate::jmap::request::MethodCall, counts: &mut TypeCounts, logger: &Logger) {
|
||||
if let Some(created) = mr.args.get("created").and_then(Value::as_object) {
|
||||
counts.created += created.len() as u64;
|
||||
}
|
||||
if let Some(nc) = mr.args.get("notCreated").and_then(Value::as_object) {
|
||||
for (cid, err) in nc {
|
||||
let etype = err.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
if etype == "alreadyExists" {
|
||||
counts.skipped += 1;
|
||||
} else {
|
||||
logger.warn(&format!("Email/import {cid} failed: {err}"));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use rusqlite::params;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::common::{create_batch, jid, target_get_all};
|
||||
use super::{Maps, Net, Plan};
|
||||
use crate::error::Error;
|
||||
use crate::jmap::wire::JmapId;
|
||||
use crate::logging::Logger;
|
||||
use crate::sync::import_jmap::mapping::{
|
||||
ADDRESS_BOOK_SELECT, CALENDAR_SELECT, row_to_address_book, row_to_calendar,
|
||||
};
|
||||
use crate::sync::keys::fold_name;
|
||||
use crate::sync::prune::{TargetObj, candidates};
|
||||
use crate::sync::{Context, TypeCounts};
|
||||
use crate::types::ObjectType;
|
||||
|
||||
pub fn reconcile(
|
||||
ctx: &Context,
|
||||
net: &Net,
|
||||
ty: ObjectType,
|
||||
maps: &mut Maps,
|
||||
counts: &mut TypeCounts,
|
||||
logger: &Logger,
|
||||
) -> Result<Plan, Error> {
|
||||
let table = crate::sync::table_name(ty);
|
||||
let select = if ty == ObjectType::AddressBook {
|
||||
ADDRESS_BOOK_SELECT
|
||||
} else {
|
||||
CALENDAR_SELECT
|
||||
};
|
||||
|
||||
let locals: Vec<(i64, String, bool)> = {
|
||||
let mut stmt = ctx
|
||||
.conn
|
||||
.prepare(&format!("SELECT id, name, is_default FROM {table}"))
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)? != 0))
|
||||
})
|
||||
.and_then(|m| m.collect::<Result<Vec<_>, _>>())
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
};
|
||||
|
||||
let targets = target_get_all(net, ty).map_err(Error::from)?;
|
||||
let mut tmatched = std::collections::HashSet::new();
|
||||
|
||||
let mut to_create: Vec<(i64, bool)> = Vec::new();
|
||||
for (local, name, is_default) in &locals {
|
||||
let hit = targets.iter().find(|t| {
|
||||
let tid = jid(t);
|
||||
t.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(|n| fold_name(n) == fold_name(name))
|
||||
.unwrap_or(false)
|
||||
&& tid.as_ref().map(|i| !tmatched.contains(i)).unwrap_or(false)
|
||||
});
|
||||
match hit.and_then(jid) {
|
||||
Some(tid) => {
|
||||
tmatched.insert(tid.clone());
|
||||
maps.insert(ty, *local, JmapId(tid));
|
||||
counts.skipped += 1;
|
||||
}
|
||||
None => to_create.push((*local, *is_default)),
|
||||
}
|
||||
}
|
||||
|
||||
if !to_create.is_empty() {
|
||||
let mut batch = Vec::new();
|
||||
for (local, _) in &to_create {
|
||||
let wire = ctx
|
||||
.conn
|
||||
.query_row(&format!("{select} WHERE id = ?1"), params![local], |row| {
|
||||
Ok(if ty == ObjectType::AddressBook {
|
||||
row_to_address_book(row).map(|w| serde_json::to_value(&w))
|
||||
} else {
|
||||
row_to_calendar(row).map(|w| serde_json::to_value(&w))
|
||||
})
|
||||
})
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
.map_err(Error::from)?
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let mut wire = wire;
|
||||
if let Value::Object(m) = &mut wire {
|
||||
m.remove("isDefault");
|
||||
}
|
||||
batch.push((format!("c{local}"), wire));
|
||||
}
|
||||
let outcome = create_batch(net, ty, batch).map_err(Error::from)?;
|
||||
for (cid, v) in &outcome.created {
|
||||
if let Some(local) = cid.strip_prefix('c').and_then(|s| s.parse::<i64>().ok())
|
||||
&& let Some(id) = jid(v)
|
||||
{
|
||||
maps.insert(ty, local, JmapId(id.clone()));
|
||||
counts.created += 1;
|
||||
if to_create.iter().any(|(l, d)| *l == local && *d) {
|
||||
let mut req = crate::jmap::request::Request::new();
|
||||
req.call(
|
||||
format!("{}/set", ty.jmap_name()),
|
||||
json!({ "accountId": net.account, "onSuccessSetIsDefault": id }),
|
||||
"d",
|
||||
);
|
||||
if let Err(e) = req.send(&net.client, &net.api) {
|
||||
logger.warn(&format!("{} isDefault not set: {e}", ty.jmap_name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (cid, err) in &outcome.not_created {
|
||||
logger.warn(&format!("{} {cid} not created: {err}", ty.jmap_name()));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let objs: Vec<TargetObj> = targets
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let id = jid(t)?;
|
||||
Some(TargetObj {
|
||||
id: id.clone(),
|
||||
matched: tmatched.contains(&id),
|
||||
protected: t.get("isDefault").and_then(Value::as_bool).unwrap_or(false),
|
||||
may_delete: t
|
||||
.get("myRights")
|
||||
.and_then(|r| r.get("mayDelete"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
parent: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Plan {
|
||||
prune_candidates: candidates(&objs, false),
|
||||
active_sieve_target: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::common::{create_batch, target_get_all};
|
||||
use super::{Maps, Net, Plan};
|
||||
use crate::error::Error;
|
||||
use crate::logging::Logger;
|
||||
use crate::sync::import_jmap::mapping::{
|
||||
IDENTITY_SELECT, PARTICIPANT_IDENTITY_SELECT, row_to_identity, row_to_participant_identity,
|
||||
};
|
||||
use crate::sync::keys::{identity_key, participant_identity_key};
|
||||
use crate::sync::{Context, TypeCounts};
|
||||
use crate::types::ObjectType;
|
||||
|
||||
fn run(
|
||||
net: &Net,
|
||||
ty: ObjectType,
|
||||
counts: &mut TypeCounts,
|
||||
logger: &Logger,
|
||||
target_key: impl Fn(&Value) -> Option<[u8; 32]>,
|
||||
local_rows: Vec<(i64, [u8; 32], Value)>,
|
||||
) -> Result<Plan, Error> {
|
||||
let targets = target_get_all(net, ty).map_err(Error::from)?;
|
||||
let mut target_keys: HashSet<[u8; 32]> = HashSet::new();
|
||||
for t in &targets {
|
||||
if let Some(k) = target_key(t) {
|
||||
target_keys.insert(k);
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen: HashSet<[u8; 32]> = HashSet::new();
|
||||
let mut batch = Vec::new();
|
||||
for (local, key, wire) in local_rows {
|
||||
if target_keys.contains(&key) || !seen.insert(key) {
|
||||
counts.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
batch.push((format!("c{local}"), wire));
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
let outcome = create_batch(net, ty, batch).map_err(Error::from)?;
|
||||
counts.created += outcome.created.len() as u64;
|
||||
for (cid, err) in &outcome.not_created {
|
||||
logger.warn(&format!(
|
||||
"{} {cid} not created (expected for non-owned addresses): {err}",
|
||||
ty.jmap_name()
|
||||
));
|
||||
counts.skipped += 1;
|
||||
}
|
||||
}
|
||||
Ok(Plan::default())
|
||||
}
|
||||
|
||||
pub fn reconcile_identity(
|
||||
ctx: &Context,
|
||||
net: &Net,
|
||||
_maps: &mut Maps,
|
||||
counts: &mut TypeCounts,
|
||||
logger: &Logger,
|
||||
) -> Result<Plan, Error> {
|
||||
let mut stmt = ctx
|
||||
.conn
|
||||
.prepare(IDENTITY_SELECT)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let rows: Vec<(i64, [u8; 32], Value)> = stmt
|
||||
.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
Ok((id, row_to_identity(row)))
|
||||
})
|
||||
.and_then(|m| m.collect::<Result<Vec<_>, _>>())
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
.into_iter()
|
||||
.map(|(id, w)| {
|
||||
let w = w.map_err(Error::from)?;
|
||||
let key = identity_key(&w.name, &w.email);
|
||||
let v = serde_json::to_value(&w).map_err(|e| Error::Partial(e.to_string()))?;
|
||||
Ok((id, key, v))
|
||||
})
|
||||
.collect::<Result<_, Error>>()?;
|
||||
drop(stmt);
|
||||
run(
|
||||
net,
|
||||
ObjectType::Identity,
|
||||
counts,
|
||||
logger,
|
||||
|t| {
|
||||
Some(identity_key(
|
||||
t.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
t.get("email").and_then(Value::as_str).unwrap_or(""),
|
||||
))
|
||||
},
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reconcile_participant(
|
||||
ctx: &Context,
|
||||
net: &Net,
|
||||
_maps: &mut Maps,
|
||||
counts: &mut TypeCounts,
|
||||
logger: &Logger,
|
||||
) -> Result<Plan, Error> {
|
||||
let mut stmt = ctx
|
||||
.conn
|
||||
.prepare(PARTICIPANT_IDENTITY_SELECT)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let rows: Vec<(i64, [u8; 32], Value)> = stmt
|
||||
.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
Ok((id, row_to_participant_identity(row)))
|
||||
})
|
||||
.and_then(|m| m.collect::<Result<Vec<_>, _>>())
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
.into_iter()
|
||||
.map(|(id, w)| {
|
||||
let w = w.map_err(Error::from)?;
|
||||
let key = participant_identity_key(&w.calendar_address, &w.name);
|
||||
let v = serde_json::to_value(&w).map_err(|e| Error::Partial(e.to_string()))?;
|
||||
Ok((id, key, v))
|
||||
})
|
||||
.collect::<Result<_, Error>>()?;
|
||||
drop(stmt);
|
||||
run(
|
||||
net,
|
||||
ObjectType::ParticipantIdentity,
|
||||
counts,
|
||||
logger,
|
||||
|t| {
|
||||
Some(participant_identity_key(
|
||||
t.get("calendarAddress")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(""),
|
||||
t.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
))
|
||||
},
|
||||
rows,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::common::{create_batch, jid, target_get_all};
|
||||
use super::{Maps, Net, Plan, Uploader};
|
||||
use crate::db;
|
||||
use crate::error::Error;
|
||||
use crate::jmap::blobxfer;
|
||||
use crate::jmap::request::Request;
|
||||
use crate::logging::Logger;
|
||||
use crate::sync::import_jmap::mapping::{SIEVE_SELECT, row_to_sieve_script};
|
||||
use crate::sync::keys::blake3_bytes;
|
||||
use crate::sync::{Context, TypeCounts};
|
||||
use crate::types::ObjectType;
|
||||
|
||||
pub fn reconcile(
|
||||
ctx: &Context,
|
||||
net: &Net,
|
||||
_maps: &mut Maps,
|
||||
counts: &mut TypeCounts,
|
||||
logger: &Logger,
|
||||
) -> Result<Plan, Error> {
|
||||
let ty = ObjectType::SieveScript;
|
||||
let targets = target_get_all(net, ty).map_err(Error::from)?;
|
||||
|
||||
let mut target_by_key: HashMap<[u8; 32], String> = HashMap::new();
|
||||
for t in &targets {
|
||||
let (Some(id), Some(blob)) = (jid(t), t.get("blobId").and_then(Value::as_str)) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = blobxfer::download_bytes(
|
||||
&net.client,
|
||||
&net.session,
|
||||
&net.account,
|
||||
blob,
|
||||
"application/sieve",
|
||||
"script",
|
||||
)
|
||||
.map_err(Error::from)?;
|
||||
target_by_key.insert(blake3_bytes(&bytes), id);
|
||||
}
|
||||
|
||||
let locals: Vec<(i64, Option<String>, bool, i64)> = {
|
||||
let mut stmt = ctx
|
||||
.conn
|
||||
.prepare(SIEVE_SELECT)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
stmt.query_map([], |row| {
|
||||
let sr = row_to_sieve_script(row);
|
||||
Ok((row.get::<_, i64>(0)?, sr))
|
||||
})
|
||||
.and_then(|m| m.collect::<Result<Vec<_>, _>>())
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
.into_iter()
|
||||
.map(|(id, sr)| {
|
||||
let sr = sr.map_err(Error::from)?;
|
||||
Ok((id, sr.name, sr.is_active, sr.blob_local_id))
|
||||
})
|
||||
.collect::<Result<_, Error>>()?
|
||||
};
|
||||
|
||||
let mut active_target: Option<String> = None;
|
||||
let mut deactivate = false;
|
||||
let mut uploader = Uploader::new(net, &ctx.conn);
|
||||
|
||||
for (local, name, is_active, blob_local) in &locals {
|
||||
let bytes = db::blobs::blob_bytes(&ctx.conn, *blob_local)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
.ok_or_else(|| Error::Partial("sieve blob missing".to_owned()))?;
|
||||
let key = blake3_bytes(&bytes);
|
||||
let target_id = if let Some(id) = target_by_key.get(&key) {
|
||||
counts.skipped += 1;
|
||||
id.clone()
|
||||
} else {
|
||||
let blob_id = uploader
|
||||
.upload_with(*blob_local, "application/sieve")
|
||||
.map_err(Error::from)?;
|
||||
let mut obj = serde_json::Map::new();
|
||||
if let Some(n) = name {
|
||||
obj.insert("name".to_owned(), Value::String(n.clone()));
|
||||
}
|
||||
obj.insert("blobId".to_owned(), Value::String(blob_id.0));
|
||||
let outcome = create_batch(net, ty, vec![(format!("c{local}"), Value::Object(obj))])
|
||||
.map_err(Error::from)?;
|
||||
match outcome.created.first().and_then(|(_, v)| jid(v)) {
|
||||
Some(id) => {
|
||||
counts.created += 1;
|
||||
target_by_key.insert(key, id.clone());
|
||||
id
|
||||
}
|
||||
None => {
|
||||
for (cid, err) in &outcome.not_created {
|
||||
logger.warn(&format!("SieveScript {cid} not created: {err}"));
|
||||
}
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
if *is_active {
|
||||
active_target = Some(target_id);
|
||||
}
|
||||
}
|
||||
|
||||
if active_target.is_none() && locals.iter().all(|(_, _, a, _)| !*a) {
|
||||
deactivate = true;
|
||||
}
|
||||
|
||||
if !net.dry_run {
|
||||
let mut req = Request::new();
|
||||
let args = if let Some(id) = &active_target {
|
||||
json!({ "accountId": net.account, "onSuccessActivateScript": id })
|
||||
} else if deactivate {
|
||||
json!({ "accountId": net.account, "onSuccessDeactivateScript": true })
|
||||
} else {
|
||||
json!({ "accountId": net.account })
|
||||
};
|
||||
req.call("SieveScript/set", args, "a");
|
||||
if let Err(e) = req.send(&net.client, &net.api) {
|
||||
logger.warn(&format!("SieveScript activation failed: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
let local_keys: std::collections::HashSet<[u8; 32]> = locals
|
||||
.iter()
|
||||
.filter_map(|(_, _, _, b)| {
|
||||
db::blobs::blob_bytes(&ctx.conn, *b)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|by| blake3_bytes(&by))
|
||||
})
|
||||
.collect();
|
||||
let mut prune_candidates: Vec<String> = target_by_key
|
||||
.iter()
|
||||
.filter(|(k, _)| !local_keys.contains(*k))
|
||||
.map(|(_, id)| id.clone())
|
||||
.collect();
|
||||
prune_candidates.sort();
|
||||
|
||||
Ok(Plan {
|
||||
prune_candidates,
|
||||
active_sieve_target: active_target,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::params;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::common::{create_batch, jid, target_query_get};
|
||||
use super::{Net, Plan, Uploader};
|
||||
use crate::error::Error;
|
||||
use crate::jmap::wire::JmapId;
|
||||
use crate::logging::Logger;
|
||||
use crate::sync::import_jmap::mapping::{
|
||||
FILE_NODE_SELECT, MAILBOX_SELECT, TargetResolver, row_to_file_node, row_to_mailbox,
|
||||
};
|
||||
use crate::sync::keys::fold_name;
|
||||
use crate::sync::{Context, TypeCounts};
|
||||
use crate::types::ObjectType;
|
||||
|
||||
use super::Maps;
|
||||
|
||||
struct LocalNode {
|
||||
local: i64,
|
||||
parent: Option<i64>,
|
||||
name: String,
|
||||
role: Option<String>,
|
||||
}
|
||||
|
||||
struct TargetNode {
|
||||
id: String,
|
||||
parent: Option<String>,
|
||||
name: String,
|
||||
role: Option<String>,
|
||||
may_delete: bool,
|
||||
}
|
||||
|
||||
fn load_local(ctx: &Context, ty: ObjectType) -> Result<Vec<LocalNode>, Error> {
|
||||
let table = crate::sync::table_name(ty);
|
||||
let mut stmt = ctx
|
||||
.conn
|
||||
.prepare(&format!("SELECT id, parent_id, name, role FROM {table}"))
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(LocalNode {
|
||||
local: r.get(0)?,
|
||||
parent: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
role: r.get(3)?,
|
||||
})
|
||||
})
|
||||
.and_then(|m| m.collect::<Result<Vec<_>, _>>())
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn load_target(net: &Net, ty: ObjectType) -> Result<Vec<TargetNode>, Error> {
|
||||
let props: &[&str] = &["role", "name", "parentId", "myRights"];
|
||||
let list = target_query_get(net, ty, Some(props)).map_err(Error::from)?;
|
||||
Ok(list
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
Some(TargetNode {
|
||||
id: jid(v)?,
|
||||
parent: v.get("parentId").and_then(Value::as_str).map(str::to_owned),
|
||||
name: v.get("name").and_then(Value::as_str)?.to_owned(),
|
||||
role: v.get("role").and_then(Value::as_str).map(str::to_owned),
|
||||
may_delete: v
|
||||
.get("myRights")
|
||||
.and_then(|r| r.get("mayDelete"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn depth(local: i64, by: &HashMap<i64, Option<i64>>) -> usize {
|
||||
let mut d = 0;
|
||||
let mut cur = by.get(&local).copied().flatten();
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(p) = cur {
|
||||
if !seen.insert(p) {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = by.get(&p).copied().flatten();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
pub fn reconcile(
|
||||
ctx: &Context,
|
||||
net: &Net,
|
||||
ty: ObjectType,
|
||||
maps: &mut Maps,
|
||||
counts: &mut TypeCounts,
|
||||
logger: &Logger,
|
||||
) -> Result<Plan, Error> {
|
||||
let locals = load_local(ctx, ty)?;
|
||||
let targets = load_target(net, ty)?;
|
||||
|
||||
let mut matched: HashMap<i64, String> = HashMap::new();
|
||||
let mut tmatched: HashSet<String> = HashSet::new();
|
||||
|
||||
if ty == ObjectType::Mailbox {
|
||||
for t in &targets {
|
||||
if let Some(r) = &t.role
|
||||
&& let Some(l) = locals.iter().find(|l| {
|
||||
l.role.as_deref() == Some(r.as_str()) && !matched.contains_key(&l.local)
|
||||
})
|
||||
{
|
||||
matched.insert(l.local, t.id.clone());
|
||||
tmatched.insert(t.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut stack: Vec<(Option<i64>, Option<String>)> = vec![(None, None)];
|
||||
for (l, tid) in matched.clone() {
|
||||
stack.push((Some(l), Some(tid)));
|
||||
}
|
||||
while let Some((lp, tp)) = stack.pop() {
|
||||
let lchildren: Vec<&LocalNode> = locals
|
||||
.iter()
|
||||
.filter(|n| n.parent == lp && !matched.contains_key(&n.local))
|
||||
.collect();
|
||||
for ln in lchildren {
|
||||
if let Some(tn) = targets.iter().find(|t| {
|
||||
t.parent.as_deref() == tp.as_deref()
|
||||
&& !tmatched.contains(&t.id)
|
||||
&& fold_name(&t.name) == fold_name(&ln.name)
|
||||
}) {
|
||||
matched.insert(ln.local, tn.id.clone());
|
||||
tmatched.insert(tn.id.clone());
|
||||
stack.push((Some(ln.local), Some(tn.id.clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (l, tid) in &matched {
|
||||
maps.insert(ty, *l, JmapId(tid.clone()));
|
||||
}
|
||||
counts.skipped += matched.len() as u64;
|
||||
|
||||
let by: HashMap<i64, Option<i64>> = locals.iter().map(|n| (n.local, n.parent)).collect();
|
||||
let mut to_create: Vec<&LocalNode> = locals
|
||||
.iter()
|
||||
.filter(|n| !matched.contains_key(&n.local))
|
||||
.collect();
|
||||
to_create.sort_by_key(|n| depth(n.local, &by));
|
||||
let max_depth = to_create
|
||||
.iter()
|
||||
.map(|n| depth(n.local, &by))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut uploader = Uploader::new(net, &ctx.conn);
|
||||
let interleave = ty == ObjectType::FileNode;
|
||||
for d in 0..=max_depth {
|
||||
let level: Vec<&LocalNode> = to_create
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|n| depth(n.local, &by) == d)
|
||||
.collect();
|
||||
if interleave {
|
||||
for n in &level {
|
||||
if let Some(p) = n.parent
|
||||
&& maps.target(ty, p).is_none()
|
||||
{
|
||||
logger.warn(&format!(
|
||||
"{} local {} skipped: parent {} not created",
|
||||
ty.jmap_name(),
|
||||
n.local,
|
||||
p
|
||||
));
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
let obj = match build_create(ctx, ty, n.local, maps, &mut uploader) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"{} local {} skipped: {e}",
|
||||
ty.jmap_name(),
|
||||
n.local
|
||||
));
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let outcome = create_batch(net, ty, vec![(format!("c{}", n.local), obj)])
|
||||
.map_err(Error::from)?;
|
||||
for (cid, v) in &outcome.created {
|
||||
if let Some(local) = cid.strip_prefix('c').and_then(|s| s.parse::<i64>().ok())
|
||||
&& let Some(id) = jid(v)
|
||||
{
|
||||
maps.insert(ty, local, JmapId(id));
|
||||
counts.created += 1;
|
||||
}
|
||||
}
|
||||
for (cid, err) in &outcome.not_created {
|
||||
logger.warn(&format!("{} {cid} not created: {err}", ty.jmap_name()));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut batch: Vec<(String, Value)> = Vec::new();
|
||||
for n in &level {
|
||||
if let Some(p) = n.parent
|
||||
&& maps.target(ty, p).is_none()
|
||||
{
|
||||
logger.warn(&format!(
|
||||
"{} local {} skipped: parent {} not created",
|
||||
ty.jmap_name(),
|
||||
n.local,
|
||||
p
|
||||
));
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
match build_create(ctx, ty, n.local, maps, &mut uploader) {
|
||||
Ok(obj) => {
|
||||
batch.push((format!("c{}", n.local), obj));
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"{} local {} skipped: {e}",
|
||||
ty.jmap_name(),
|
||||
n.local
|
||||
));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if batch.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let outcome = create_batch(net, ty, batch).map_err(Error::from)?;
|
||||
for (cid, v) in &outcome.created {
|
||||
if let Some(local) = cid.strip_prefix('c').and_then(|s| s.parse::<i64>().ok())
|
||||
&& let Some(id) = jid(v)
|
||||
{
|
||||
maps.insert(ty, local, JmapId(id));
|
||||
counts.created += 1;
|
||||
}
|
||||
}
|
||||
for (cid, err) in &outcome.not_created {
|
||||
logger.warn(&format!("{} {cid} not created: {err}", ty.jmap_name()));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let objs: Vec<crate::sync::prune::TargetObj> = targets
|
||||
.iter()
|
||||
.map(|t| crate::sync::prune::TargetObj {
|
||||
id: t.id.clone(),
|
||||
matched: tmatched.contains(&t.id),
|
||||
protected: t.role.is_some(),
|
||||
may_delete: t.may_delete,
|
||||
parent: t.parent.clone(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Plan {
|
||||
prune_candidates: crate::sync::prune::candidates(&objs, true),
|
||||
active_sieve_target: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_create(
|
||||
ctx: &Context,
|
||||
ty: ObjectType,
|
||||
local: i64,
|
||||
maps: &Maps,
|
||||
uploader: &mut Uploader<'_>,
|
||||
) -> Result<Value, Error> {
|
||||
if ty == ObjectType::Mailbox {
|
||||
let wire = ctx
|
||||
.conn
|
||||
.query_row(
|
||||
&format!("{MAILBOX_SELECT} WHERE id = ?1"),
|
||||
params![local],
|
||||
|row| Ok(row_to_mailbox(row, maps)),
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
.map_err(Error::from)?;
|
||||
return serde_json::to_value(&wire).map_err(|e| Error::Partial(e.to_string()));
|
||||
}
|
||||
let fnrow = ctx
|
||||
.conn
|
||||
.query_row(
|
||||
&format!("{FILE_NODE_SELECT} WHERE id = ?1"),
|
||||
params![local],
|
||||
|row| Ok(row_to_file_node(row, maps)),
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
.map_err(Error::from)?;
|
||||
let mut wire = fnrow.wire;
|
||||
if let (crate::jmap::wire::file_node::NodeType::File, Some(blob_local)) =
|
||||
(&wire.node_type, fnrow.blob_local_id)
|
||||
{
|
||||
let ct = wire
|
||||
.media_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "application/octet-stream".to_owned());
|
||||
let id = uploader.upload_with(blob_local, &ct).map_err(Error::from)?;
|
||||
wire.blob_id = Some(id);
|
||||
}
|
||||
let mut obj = serde_json::to_value(&wire).map_err(|e| Error::Partial(e.to_string()))?;
|
||||
if let Value::Object(m) = &mut obj {
|
||||
m.remove("created");
|
||||
m.remove("modified");
|
||||
m.remove("isSubscribed");
|
||||
}
|
||||
Ok(obj)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::common::{create_batch, jid, target_query_get};
|
||||
use super::{Maps, Net, Plan, Uploader};
|
||||
use crate::error::Error;
|
||||
use crate::logging::Logger;
|
||||
use crate::sync::import_jmap::mapping::{
|
||||
CALENDAR_EVENT_SELECT, CONTACT_CARD_SELECT, calendar_event_to_wire, contact_card_to_wire,
|
||||
};
|
||||
use crate::sync::prune::{TargetObj, candidates};
|
||||
use crate::sync::{Context, TypeCounts};
|
||||
use crate::types::ObjectType;
|
||||
|
||||
fn target_uid(v: &Value) -> Option<String> {
|
||||
v.get("uid").and_then(Value::as_str).map(str::to_owned)
|
||||
}
|
||||
|
||||
pub fn reconcile(
|
||||
ctx: &Context,
|
||||
net: &Net,
|
||||
ty: ObjectType,
|
||||
maps: &mut Maps,
|
||||
counts: &mut TypeCounts,
|
||||
logger: &Logger,
|
||||
) -> Result<Plan, Error> {
|
||||
let targets = target_query_get(net, ty, None).map_err(Error::from)?;
|
||||
let mut by_uid: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
for t in &targets {
|
||||
if let (Some(uid), Some(id)) = (target_uid(t), jid(t)) {
|
||||
by_uid.entry(uid).or_insert(id);
|
||||
}
|
||||
}
|
||||
|
||||
let select = if ty == ObjectType::ContactCard {
|
||||
CONTACT_CARD_SELECT
|
||||
} else {
|
||||
CALENDAR_EVENT_SELECT
|
||||
};
|
||||
let rows: Vec<(i64, String)> = {
|
||||
let mut stmt = ctx
|
||||
.conn
|
||||
.prepare(select)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
stmt.query_map([], |r| {
|
||||
if ty == ObjectType::ContactCard {
|
||||
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))
|
||||
} else {
|
||||
let data: String = r.get(4)?;
|
||||
let uid = serde_json::from_str::<Value>(&data)
|
||||
.ok()
|
||||
.and_then(|v| v.get("uid").and_then(Value::as_str).map(str::to_owned))
|
||||
.unwrap_or_default();
|
||||
Ok((r.get::<_, i64>(0)?, uid))
|
||||
}
|
||||
})
|
||||
.and_then(|m| m.collect::<Result<Vec<_>, _>>())
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
};
|
||||
|
||||
let mut matched_uids: HashSet<String> = HashSet::new();
|
||||
let mut uploader = Uploader::new(net, &ctx.conn);
|
||||
for (local, uid) in &rows {
|
||||
if let Some(tid) = by_uid.get(uid) {
|
||||
maps.insert(ty, *local, crate::jmap::wire::JmapId(tid.clone()));
|
||||
matched_uids.insert(uid.clone());
|
||||
counts.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let wire = match build_wire(ctx, ty, *local, maps, &mut uploader) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
logger.warn(&format!("{} local {local} skipped: {e}", ty.jmap_name()));
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let outcome =
|
||||
create_batch(net, ty, vec![(format!("c{local}"), wire)]).map_err(Error::from)?;
|
||||
for (cid, v) in &outcome.created {
|
||||
if let Some(parsed) = cid.strip_prefix('c').and_then(|s| s.parse::<i64>().ok())
|
||||
&& let Some(id) = jid(v)
|
||||
{
|
||||
maps.insert(ty, parsed, crate::jmap::wire::JmapId(id));
|
||||
counts.created += 1;
|
||||
}
|
||||
}
|
||||
for (cid, err) in &outcome.not_created {
|
||||
logger.warn(&format!("{} {cid} not created: {err}", ty.jmap_name()));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let objs: Vec<TargetObj> = targets
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let id = jid(t)?;
|
||||
let uid = target_uid(t);
|
||||
Some(TargetObj {
|
||||
id: id.clone(),
|
||||
matched: uid.map(|u| matched_uids.contains(&u)).unwrap_or(false),
|
||||
protected: false,
|
||||
may_delete: true,
|
||||
parent: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Plan {
|
||||
prune_candidates: candidates(&objs, false),
|
||||
active_sieve_target: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_wire(
|
||||
ctx: &Context,
|
||||
ty: ObjectType,
|
||||
local: i64,
|
||||
maps: &Maps,
|
||||
up: &mut Uploader<'_>,
|
||||
) -> Result<Value, Error> {
|
||||
if ty == ObjectType::ContactCard {
|
||||
let (uid, abids, data): (String, String, String) = ctx
|
||||
.conn
|
||||
.query_row(
|
||||
&format!("{CONTACT_CARD_SELECT} WHERE id = ?1"),
|
||||
rusqlite::params![local],
|
||||
|r| Ok((r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
contact_card_to_wire(&uid, &abids, &data, maps, up).map_err(Error::from)
|
||||
} else {
|
||||
let (cal, dr, ud, data): (String, i64, i64, String) = ctx
|
||||
.conn
|
||||
.query_row(
|
||||
&format!("{CALENDAR_EVENT_SELECT} AND id = ?1"),
|
||||
rusqlite::params![local],
|
||||
|r| Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
calendar_event_to_wire(&cal, dr != 0, ud != 0, &data, maps, up).map_err(Error::from)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod calcard;
|
||||
pub mod collections;
|
||||
pub mod coordinator;
|
||||
pub mod items;
|
||||
pub mod tree;
|
||||
|
||||
pub use coordinator::{DavAuth, DavImportConfig, DavKindArg, run};
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use calcard::icalendar::ICalendar;
|
||||
use calcard::vcard::VCard;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CalcardError {
|
||||
#[error("vcard parse failed")]
|
||||
VCardParse,
|
||||
#[error("icalendar parse failed")]
|
||||
ICalParse,
|
||||
#[error("jscontact serialisation: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("no parseable entries in iCalendar")]
|
||||
NoEntries,
|
||||
#[error("calcard output is not a JSON object")]
|
||||
NotAnObject,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JsContact {
|
||||
pub uid: String,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
pub fn vcard_to_jscontact(text: &str, item_href: &str) -> Result<JsContact, CalcardError> {
|
||||
let vcard = VCard::parse(text).map_err(|_| CalcardError::VCardParse)?;
|
||||
let js = vcard.into_jscontact::<String, String>();
|
||||
let value: Value = serde_json::from_str(&js.to_string_pretty())?;
|
||||
let uid = value
|
||||
.get("uid")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| synthesise_uid(item_href));
|
||||
let mut obj = match value {
|
||||
Value::Object(map) => map,
|
||||
_ => return Err(CalcardError::NotAnObject),
|
||||
};
|
||||
obj.remove("uid");
|
||||
Ok(JsContact {
|
||||
uid,
|
||||
data: Value::Object(obj),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn synthesise_uid(item_href: &str) -> String {
|
||||
let hash = blake3::hash(item_href.as_bytes());
|
||||
format!("vandelay-syn-{}", hash.to_hex())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JsCalendarEntry {
|
||||
pub data: Value,
|
||||
pub data_type: EntryType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EntryType {
|
||||
Event,
|
||||
Task,
|
||||
Note,
|
||||
Group,
|
||||
}
|
||||
|
||||
impl EntryType {
|
||||
pub fn as_column(self) -> &'static str {
|
||||
match self {
|
||||
EntryType::Event => "Event",
|
||||
EntryType::Task => "Task",
|
||||
EntryType::Note => "Note",
|
||||
EntryType::Group => "Group",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ical_to_jscalendar_entries(text: &str) -> Result<Vec<JsCalendarEntry>, CalcardError> {
|
||||
let ical = ICalendar::parse(text).map_err(|_| CalcardError::ICalParse)?;
|
||||
let js = ical.into_jscalendar::<String, String>();
|
||||
let value: Value = serde_json::from_str(&js.to_string_pretty())?;
|
||||
let mut out = Vec::new();
|
||||
collect_entries(value, &mut out);
|
||||
if out.is_empty() {
|
||||
return Err(CalcardError::NoEntries);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect_entries(value: Value, out: &mut Vec<JsCalendarEntry>) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let at_type = map.get("@type").and_then(Value::as_str).map(str::to_owned);
|
||||
match at_type.as_deref() {
|
||||
Some("Event") if has_nonempty_uid(&map) => out.push(JsCalendarEntry {
|
||||
data: Value::Object(map),
|
||||
data_type: EntryType::Event,
|
||||
}),
|
||||
Some("Task") if has_nonempty_uid(&map) => out.push(JsCalendarEntry {
|
||||
data: Value::Object(map),
|
||||
data_type: EntryType::Task,
|
||||
}),
|
||||
Some("Note") if has_nonempty_uid(&map) => out.push(JsCalendarEntry {
|
||||
data: Value::Object(map),
|
||||
data_type: EntryType::Note,
|
||||
}),
|
||||
Some("Group") => {
|
||||
if let Some(entries) = map.get("entries").and_then(Value::as_array) {
|
||||
for entry in entries {
|
||||
collect_entries(entry.clone(), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_entries(item, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_nonempty_uid(map: &Map<String, Value>) -> bool {
|
||||
map.get("uid")
|
||||
.and_then(Value::as_str)
|
||||
.map(|u| !u.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn strip_extracted_fields_from_event(value: &mut Value) -> (bool, bool, Option<String>) {
|
||||
let Value::Object(map) = value else {
|
||||
return (false, false, None);
|
||||
};
|
||||
let is_draft = map
|
||||
.remove("isDraft")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let use_default_alerts = map
|
||||
.remove("useDefaultAlerts")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
map.remove("calendarIds");
|
||||
map.remove("method");
|
||||
map.remove("utcStart");
|
||||
map.remove("utcEnd");
|
||||
map.remove("isOrigin");
|
||||
map.remove("baseEventId");
|
||||
let uid = map.get("uid").and_then(Value::as_str).map(str::to_owned);
|
||||
(is_draft, use_default_alerts, uid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_VCARD: &str = "BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
UID:abc-123\r\n\
|
||||
FN:Alice Smith\r\n\
|
||||
EMAIL:[email protected]\r\n\
|
||||
END:VCARD\r\n";
|
||||
|
||||
const SAMPLE_ICAL: &str = "BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//Test//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:[email protected]\r\n\
|
||||
DTSTAMP:20260101T000000Z\r\n\
|
||||
DTSTART:20260101T090000Z\r\n\
|
||||
DTEND:20260101T100000Z\r\n\
|
||||
SUMMARY:Test Event\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n";
|
||||
|
||||
#[test]
|
||||
fn vcard_extracts_uid_into_separate_field() {
|
||||
let jc = vcard_to_jscontact(SAMPLE_VCARD, "/dav/card/u/d/a.vcf").expect("parse");
|
||||
assert_eq!(jc.uid, "abc-123");
|
||||
let obj = jc.data.as_object().unwrap();
|
||||
assert!(!obj.contains_key("uid"), "uid stripped from data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ical_extracts_at_least_one_event() {
|
||||
let events = ical_to_jscalendar_entries(SAMPLE_ICAL).expect("parse");
|
||||
assert!(!events.is_empty());
|
||||
assert_eq!(events[0].data_type, EntryType::Event);
|
||||
let uid = events[0]
|
||||
.data
|
||||
.as_object()
|
||||
.and_then(|m| m.get("uid"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
assert!(uid.starts_with("event-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcard_without_uid_gets_synthetic_uid_from_href() {
|
||||
let no_uid = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Anon\r\nEND:VCARD\r\n";
|
||||
let href = "/dav/card/u/d/no-uid.vcf";
|
||||
let jc = vcard_to_jscontact(no_uid, href).expect("synthetic uid");
|
||||
assert_eq!(jc.uid, synthesise_uid(href));
|
||||
assert!(jc.uid.starts_with("vandelay-syn-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_uid_is_stable_per_href() {
|
||||
assert_eq!(synthesise_uid("/a"), synthesise_uid("/a"));
|
||||
assert_ne!(synthesise_uid("/a"), synthesise_uid("/b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ical_with_empty_calendar_fails_no_entries() {
|
||||
let empty = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n";
|
||||
let err = ical_to_jscalendar_entries(empty).unwrap_err();
|
||||
assert!(matches!(err, CalcardError::NoEntries));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcard_garbage_fails_parse() {
|
||||
let err = vcard_to_jscontact("not a vcard", "/x").unwrap_err();
|
||||
assert!(matches!(err, CalcardError::VCardParse));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ical_vtodo_classified_as_task() {
|
||||
let vtodo = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
|
||||
BEGIN:VTODO\r\nUID:t1@example\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
SUMMARY:Buy milk\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
|
||||
let entries = ical_to_jscalendar_entries(vtodo).expect("parse vtodo");
|
||||
assert_eq!(entries[0].data_type, EntryType::Task);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ical_recurrence_overrides_collapse_to_single_event() {
|
||||
let ical = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\nUID:r1@example\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
DTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\n\
|
||||
RRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n\
|
||||
BEGIN:VEVENT\r\nUID:r1@example\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
RECURRENCE-ID:20260102T090000Z\r\nDTSTART:20260102T093000Z\r\nDTEND:20260102T103000Z\r\n\
|
||||
SUMMARY:Standup (moved)\r\nEND:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n";
|
||||
let entries = ical_to_jscalendar_entries(ical).expect("parse");
|
||||
assert_eq!(entries.len(), 1, "calcard merges overrides into master");
|
||||
let master = entries[0].data.as_object().unwrap();
|
||||
assert!(master.contains_key("recurrenceOverrides"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_extracted_fields_pulls_isdraft_use_default_alerts() {
|
||||
let mut v = serde_json::json!({
|
||||
"@type": "Event",
|
||||
"uid": "x",
|
||||
"title": "T",
|
||||
"isDraft": true,
|
||||
"useDefaultAlerts": true,
|
||||
"calendarIds": {"a": true},
|
||||
"utcStart": "2026-01-01T09:00:00Z",
|
||||
"method": "PUBLISH"
|
||||
});
|
||||
let (d, u, uid) = strip_extracted_fields_from_event(&mut v);
|
||||
assert!(d);
|
||||
assert!(u);
|
||||
assert_eq!(uid.as_deref(), Some("x"));
|
||||
let m = v.as_object().unwrap();
|
||||
assert!(!m.contains_key("calendarIds"));
|
||||
assert!(!m.contains_key("isDraft"));
|
||||
assert!(!m.contains_key("useDefaultAlerts"));
|
||||
assert!(!m.contains_key("method"));
|
||||
assert!(!m.contains_key("utcStart"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::dav::discover::DiscoveredCollection;
|
||||
use crate::dav::href::{Href, last_path_component};
|
||||
use crate::db::dav_ids;
|
||||
use crate::error::Error;
|
||||
use crate::logging::{LEVEL_PROGRESS, Logger};
|
||||
use crate::sync::TypeCounts;
|
||||
|
||||
pub fn reconcile_calendars(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
discovered: &[DiscoveredCollection],
|
||||
counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<Vec<(String, i64)>, Error> {
|
||||
reconcile_calendar_like(
|
||||
conn,
|
||||
source_id,
|
||||
discovered,
|
||||
&RowOps {
|
||||
type_name: dav_ids::CALENDAR,
|
||||
upsert: upsert_calendar_row,
|
||||
delete: delete_calendar_row,
|
||||
},
|
||||
counts,
|
||||
logger,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reconcile_address_books(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
discovered: &[DiscoveredCollection],
|
||||
counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<Vec<(String, i64)>, Error> {
|
||||
reconcile_calendar_like(
|
||||
conn,
|
||||
source_id,
|
||||
discovered,
|
||||
&RowOps {
|
||||
type_name: dav_ids::ADDRESS_BOOK,
|
||||
upsert: upsert_address_book_row,
|
||||
delete: delete_address_book_row,
|
||||
},
|
||||
counts,
|
||||
logger,
|
||||
)
|
||||
}
|
||||
|
||||
type RowUpsert =
|
||||
fn(&Connection, &DiscoveredCollection, Option<i64>) -> Result<i64, rusqlite::Error>;
|
||||
type RowDelete = fn(&Connection, i64) -> Result<(), rusqlite::Error>;
|
||||
|
||||
struct RowOps {
|
||||
type_name: &'static str,
|
||||
upsert: RowUpsert,
|
||||
delete: RowDelete,
|
||||
}
|
||||
|
||||
fn reconcile_calendar_like(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
discovered: &[DiscoveredCollection],
|
||||
ops: &RowOps,
|
||||
counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<Vec<(String, i64)>, Error> {
|
||||
let type_name = ops.type_name;
|
||||
let upsert_row = ops.upsert;
|
||||
let delete_row = ops.delete;
|
||||
let existing: HashMap<String, i64> = dav_ids::collections_of_type(conn, source_id, type_name)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
|
||||
let mut upserted: Vec<(String, i64)> = Vec::new();
|
||||
let server_hrefs: std::collections::HashSet<String> = discovered
|
||||
.iter()
|
||||
.map(|c| c.href.as_str().to_owned())
|
||||
.collect();
|
||||
|
||||
for coll in discovered {
|
||||
let collection_href = coll.href.as_str().to_owned();
|
||||
let existing_local = existing.get(&collection_href).copied();
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
match upsert_row(&tx, coll, existing_local) {
|
||||
Ok(local_id) => {
|
||||
if existing_local.is_none() {
|
||||
dav_ids::insert(
|
||||
&tx,
|
||||
source_id,
|
||||
type_name,
|
||||
&collection_href,
|
||||
&collection_href,
|
||||
"",
|
||||
local_id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.created += 1;
|
||||
} else {
|
||||
counts.fetched += 1;
|
||||
}
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
upserted.push((collection_href.clone(), local_id));
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!("collection upserted: {collection_href} -> {local_id}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
logger.warn(&format!(
|
||||
"collection upsert {collection_href:?} failed: {e}"
|
||||
));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (collection_href, local_id) in &existing {
|
||||
if server_hrefs.contains(collection_href) {
|
||||
continue;
|
||||
}
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let item_type = item_type_for(type_name);
|
||||
if let Some(item_type) = item_type {
|
||||
let _ = dav_ids::delete_collection(&tx, source_id, item_type, collection_href);
|
||||
}
|
||||
let _ = dav_ids::delete_collection(&tx, source_id, type_name, collection_href);
|
||||
match delete_row(&tx, *local_id) {
|
||||
Ok(()) => {
|
||||
counts.deleted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"vanished collection {collection_href:?} delete failed: {e}"
|
||||
));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(upserted)
|
||||
}
|
||||
|
||||
fn item_type_for(collection_type: &str) -> Option<&'static str> {
|
||||
match collection_type {
|
||||
dav_ids::CALENDAR => Some(dav_ids::CALENDAR_EVENT),
|
||||
dav_ids::ADDRESS_BOOK => Some(dav_ids::CONTACT_CARD),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_calendar_row(
|
||||
conn: &Connection,
|
||||
coll: &DiscoveredCollection,
|
||||
existing_local: Option<i64>,
|
||||
) -> Result<i64, rusqlite::Error> {
|
||||
let name = display_or_fallback(&coll.props.displayname, &coll.href);
|
||||
let description = coll.props.calendar_description.as_deref();
|
||||
let color = coll.props.calendar_color.as_deref();
|
||||
let sort_order = coll.props.calendar_order.unwrap_or(0).max(0);
|
||||
let time_zone = parse_tzid_from_vtimezone(coll.props.calendar_timezone.as_deref());
|
||||
|
||||
if let Some(local) = existing_local {
|
||||
conn.execute(
|
||||
"UPDATE calendars SET name = ?1, description = ?2, color = ?3, sort_order = ?4,
|
||||
time_zone = ?5
|
||||
WHERE id = ?6",
|
||||
params![name, description, color, sort_order, time_zone, local],
|
||||
)?;
|
||||
Ok(local)
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO calendars (name, description, color, sort_order, is_subscribed,
|
||||
is_visible, is_default, include_in_availability,
|
||||
default_alerts_with_time, default_alerts_without_time, time_zone)
|
||||
VALUES (?1, ?2, ?3, ?4, 1, 1, 0, 'all', NULL, NULL, ?5)",
|
||||
params![name, description, color, sort_order, time_zone],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_calendar_row(conn: &Connection, local_id: i64) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"DELETE FROM calendar_events
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM json_each(calendar_ids) WHERE value = ?1
|
||||
)",
|
||||
params![local_id],
|
||||
)?;
|
||||
conn.execute("DELETE FROM calendars WHERE id = ?1", params![local_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_address_book_row(
|
||||
conn: &Connection,
|
||||
coll: &DiscoveredCollection,
|
||||
existing_local: Option<i64>,
|
||||
) -> Result<i64, rusqlite::Error> {
|
||||
let name = display_or_fallback(&coll.props.displayname, &coll.href);
|
||||
let description = coll.props.addressbook_description.as_deref();
|
||||
|
||||
if let Some(local) = existing_local {
|
||||
conn.execute(
|
||||
"UPDATE address_books SET name = ?1, description = ?2 WHERE id = ?3",
|
||||
params![name, description, local],
|
||||
)?;
|
||||
Ok(local)
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO address_books (name, description, sort_order, is_default, is_subscribed)
|
||||
VALUES (?1, ?2, 0, 0, 1)",
|
||||
params![name, description],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_address_book_row(conn: &Connection, local_id: i64) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"DELETE FROM contact_cards
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM json_each(address_book_ids) WHERE value = ?1
|
||||
)",
|
||||
params![local_id],
|
||||
)?;
|
||||
conn.execute("DELETE FROM address_books WHERE id = ?1", params![local_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_or_fallback(name: &Option<String>, href: &Href) -> String {
|
||||
if let Some(n) = name
|
||||
&& !n.trim().is_empty()
|
||||
{
|
||||
return n.clone();
|
||||
}
|
||||
let comp = last_path_component(href);
|
||||
if comp.is_empty() {
|
||||
"Untitled".to_owned()
|
||||
} else {
|
||||
comp
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tzid_from_vtimezone(vt: Option<&str>) -> Option<String> {
|
||||
let body = vt?;
|
||||
for line in body.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.strip_prefix("TZID:") {
|
||||
let v = rest.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn upsert_root_directory(
|
||||
conn: &Connection,
|
||||
source_id: i64,
|
||||
root: &DiscoveredCollection,
|
||||
) -> Result<i64, Error> {
|
||||
let collection_href = root.href.as_str().to_owned();
|
||||
let name = display_or_fallback(&root.props.displayname, &root.href);
|
||||
if let Some(local) =
|
||||
dav_ids::local_for_item(conn, source_id, dav_ids::FILE_NODE, &collection_href)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
{
|
||||
conn.execute(
|
||||
"UPDATE file_nodes SET name = ?1 WHERE id = ?2",
|
||||
params![name, local],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
return Ok(local);
|
||||
}
|
||||
let now = time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|e| Error::Partial(format!("clock: {e}")))?;
|
||||
conn.execute(
|
||||
"INSERT INTO file_nodes (parent_id, node_type, blob_id, target, name, media_type,
|
||||
created, modified, is_subscribed, role)
|
||||
VALUES (NULL, 'directory', NULL, NULL, ?1, NULL, ?2, NULL, 1, NULL)",
|
||||
params![name, now],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let local_id = conn.last_insert_rowid();
|
||||
dav_ids::insert(
|
||||
conn,
|
||||
source_id,
|
||||
dav_ids::FILE_NODE,
|
||||
&collection_href,
|
||||
&collection_href,
|
||||
"",
|
||||
local_id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
Ok(local_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_tzid_from_vtimezone_extracts_first() {
|
||||
let v = "BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nEND:VTIMEZONE\r\n";
|
||||
assert_eq!(
|
||||
parse_tzid_from_vtimezone(Some(v)).as_deref(),
|
||||
Some("Europe/Berlin")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tzid_returns_none_when_absent() {
|
||||
assert!(parse_tzid_from_vtimezone(Some("nothing here")).is_none());
|
||||
assert!(parse_tzid_from_vtimezone(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_or_fallback_uses_href_when_displayname_empty() {
|
||||
let href = Href::from_normalised("/dav/cal/u/work/".to_owned());
|
||||
let name: Option<String> = None;
|
||||
assert_eq!(display_or_fallback(&name, &href), "work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_or_fallback_uses_displayname_when_present() {
|
||||
let href = Href::from_normalised("/dav/cal/u/default/".to_owned());
|
||||
let name = Some("Default".to_owned());
|
||||
assert_eq!(display_or_fallback(&name, &href), "Default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_calendar_row_cascades_events_and_sync_id_rows() {
|
||||
use crate::db::init;
|
||||
use crate::db::sources::{SourceKey, upsert_source};
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
init::apply_schema(&conn).unwrap();
|
||||
let sid = upsert_source(
|
||||
&conn,
|
||||
&SourceKey {
|
||||
kind: "caldav".to_owned(),
|
||||
session_url: "https://x".to_owned(),
|
||||
account_id: "u".to_owned(),
|
||||
},
|
||||
Some("u"),
|
||||
"u",
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO calendars (name, sort_order, is_subscribed, is_visible, is_default,
|
||||
include_in_availability)
|
||||
VALUES ('Work', 0, 1, 1, 0, 'all')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let cal_local = conn.last_insert_rowid();
|
||||
conn.execute(
|
||||
"INSERT INTO calendar_events (calendar_ids, data, data_type)
|
||||
VALUES (?1, '{}', 'Event')",
|
||||
rusqlite::params![format!("[{cal_local}]")],
|
||||
)
|
||||
.unwrap();
|
||||
let evt_local = conn.last_insert_rowid();
|
||||
dav_ids::insert(
|
||||
&conn,
|
||||
sid,
|
||||
dav_ids::CALENDAR,
|
||||
"/cal/work/",
|
||||
"/cal/work/",
|
||||
"",
|
||||
cal_local,
|
||||
)
|
||||
.unwrap();
|
||||
dav_ids::insert(
|
||||
&conn,
|
||||
sid,
|
||||
dav_ids::CALENDAR_EVENT,
|
||||
"/cal/work/",
|
||||
"/cal/work/e.ics",
|
||||
"\"v\"",
|
||||
evt_local,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = dav_ids::delete_collection(&conn, sid, dav_ids::CALENDAR_EVENT, "/cal/work/");
|
||||
let _ = dav_ids::delete_collection(&conn, sid, dav_ids::CALENDAR, "/cal/work/");
|
||||
delete_calendar_row(&conn, cal_local).unwrap();
|
||||
let cal_count: i64 = conn
|
||||
.query_row("SELECT count(*) FROM calendars", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let evt_count: i64 = conn
|
||||
.query_row("SELECT count(*) FROM calendar_events", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let dav_count: i64 = conn
|
||||
.query_row("SELECT count(*) FROM sync_id_dav", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(cal_count, 0, "calendar row gone");
|
||||
assert_eq!(evt_count, 0, "event rows cascaded");
|
||||
assert_eq!(dav_count, 0, "sync_id_dav rows for both types cleared");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::dav::client::DavClient;
|
||||
use crate::dav::discover::{DavKind, Discovery, DiscoveryError, discover};
|
||||
use crate::db;
|
||||
use crate::db::sources::SourceKey;
|
||||
use crate::error::Error;
|
||||
use crate::jmap::http::{Auth, RetryPolicy};
|
||||
use crate::logging::{LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
|
||||
use crate::sync::{CommonConfig, Summary, TypeCounts};
|
||||
|
||||
use super::collections;
|
||||
use super::items;
|
||||
use super::tree;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum DavKindArg {
|
||||
Caldav,
|
||||
Carddav,
|
||||
Webdav,
|
||||
}
|
||||
|
||||
impl DavKindArg {
|
||||
pub fn kind(self) -> DavKind {
|
||||
match self {
|
||||
DavKindArg::Caldav => DavKind::Caldav,
|
||||
DavKindArg::Carddav => DavKind::Carddav,
|
||||
DavKindArg::Webdav => DavKind::Webdav,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_kind(self) -> &'static str {
|
||||
match self {
|
||||
DavKindArg::Caldav => "caldav",
|
||||
DavKindArg::Carddav => "carddav",
|
||||
DavKindArg::Webdav => "webdav",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DavAuth {
|
||||
Basic { user: String, password: String },
|
||||
Bearer { token: String },
|
||||
}
|
||||
|
||||
impl DavAuth {
|
||||
pub fn to_jmap_auth(&self) -> Auth {
|
||||
match self {
|
||||
DavAuth::Basic { user, password } => Auth::Basic {
|
||||
user: user.clone(),
|
||||
password: password.clone(),
|
||||
},
|
||||
DavAuth::Bearer { token } => Auth::Bearer {
|
||||
token: token.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn username(&self) -> String {
|
||||
match self {
|
||||
DavAuth::Basic { user, .. } => user.clone(),
|
||||
DavAuth::Bearer { .. } => "(bearer)".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DavImportConfig {
|
||||
pub kind: DavKindArg,
|
||||
pub url: String,
|
||||
pub auth: DavAuth,
|
||||
pub allow_cleartext: bool,
|
||||
pub dav_connections: usize,
|
||||
pub multiget_batch: usize,
|
||||
pub allow_source_change: bool,
|
||||
}
|
||||
|
||||
pub fn run(common: CommonConfig, config: DavImportConfig) -> Result<Summary, Error> {
|
||||
let logger = common.logger;
|
||||
enforce_tls_policy(&config.url, config.allow_cleartext)?;
|
||||
|
||||
let mut conn = db::init::open(&common.archive)?;
|
||||
|
||||
let client = DavClient::new(
|
||||
config.auth.to_jmap_auth(),
|
||||
RetryPolicy::new(common.max_retries),
|
||||
common.allow_invalid_certs,
|
||||
);
|
||||
client.set_logger(logger);
|
||||
|
||||
let discovery =
|
||||
discover(&client, config.kind.kind(), &config.url).map_err(map_discovery_error)?;
|
||||
|
||||
let session_url = normalise_base_url(&config.url);
|
||||
let account_id_raw = discovery
|
||||
.principal_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| discovery.home_set_url.clone());
|
||||
let account_id = normalise_account_url(&account_id_raw);
|
||||
|
||||
let key = SourceKey {
|
||||
kind: config.kind.source_kind().to_owned(),
|
||||
session_url: session_url.clone(),
|
||||
account_id: account_id.clone(),
|
||||
};
|
||||
|
||||
if !common.dry_run
|
||||
&& let Some((url, acc)) = db::sources::conflicting_source(
|
||||
&conn,
|
||||
config.kind.source_kind(),
|
||||
&session_url,
|
||||
&account_id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
&& !config.allow_source_change
|
||||
{
|
||||
return Err(Error::SourceChange(format!(
|
||||
"archive already records {} source ({url}, account {acc}); \
|
||||
pass --allow-source-change to import a different account",
|
||||
config.kind.source_kind()
|
||||
)));
|
||||
}
|
||||
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"DAV discovery: principal={:?} home_set={} collections={}",
|
||||
discovery.principal_url,
|
||||
discovery.home_set_url,
|
||||
discovery.collections.len()
|
||||
);
|
||||
}
|
||||
|
||||
if common.dry_run {
|
||||
let mut summary = run_dry_diff(&conn, &client, &discovery, &config, logger)?;
|
||||
summary.retries_observed = client.retries_observed();
|
||||
summary.retry_after_sleeps = client.retry_after_sleeps();
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
let username = config.auth.username();
|
||||
let source_id = db::sources::upsert_source(&conn, &key, Some(&account_id), &username)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
|
||||
let summary = match config.kind {
|
||||
DavKindArg::Caldav => {
|
||||
run_caldav(&mut conn, &client, source_id, &discovery, &config, logger)?
|
||||
}
|
||||
DavKindArg::Carddav => {
|
||||
run_carddav(&mut conn, &client, source_id, &discovery, &config, logger)?
|
||||
}
|
||||
DavKindArg::Webdav => {
|
||||
run_webdav(&mut conn, &client, source_id, &discovery, &config, logger)?
|
||||
}
|
||||
};
|
||||
|
||||
if !summary.any_failed()
|
||||
&& let Err(e) = run_gc(&conn)
|
||||
{
|
||||
logger.warn(&format!("blob GC skipped: {e}"));
|
||||
}
|
||||
|
||||
let mut summary = summary;
|
||||
summary.retries_observed = client.retries_observed();
|
||||
summary.retry_after_sleeps = client.retry_after_sleeps();
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
type ReconcileCollections = fn(
|
||||
&mut Connection,
|
||||
i64,
|
||||
&[crate::dav::discover::DiscoveredCollection],
|
||||
&mut TypeCounts,
|
||||
Logger,
|
||||
) -> Result<Vec<(String, i64)>, Error>;
|
||||
|
||||
type ReconcileItems =
|
||||
fn(&mut Connection, &items::ItemRunCtx<'_>, &str, i64, &mut TypeCounts) -> Result<(), Error>;
|
||||
|
||||
struct ItemPhase {
|
||||
container_label: &'static str,
|
||||
item_label: &'static str,
|
||||
reconcile_collections: ReconcileCollections,
|
||||
reconcile_items: ReconcileItems,
|
||||
}
|
||||
|
||||
fn run_collection_phase(
|
||||
conn: &mut Connection,
|
||||
client: &DavClient,
|
||||
source_id: i64,
|
||||
discovery: &Discovery,
|
||||
config: &DavImportConfig,
|
||||
logger: Logger,
|
||||
phase: ItemPhase,
|
||||
) -> Result<Summary, Error> {
|
||||
let mut summary = Summary::default();
|
||||
let mut container_counts = TypeCounts::default();
|
||||
let mut item_counts = TypeCounts::default();
|
||||
|
||||
let upserted = (phase.reconcile_collections)(
|
||||
conn,
|
||||
source_id,
|
||||
&discovery.collections,
|
||||
&mut container_counts,
|
||||
logger,
|
||||
)?;
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!(
|
||||
"import: {} done (upserted={} deleted={} failed={})",
|
||||
phase.container_label,
|
||||
container_counts.created + container_counts.fetched,
|
||||
container_counts.deleted,
|
||||
container_counts.failed
|
||||
);
|
||||
}
|
||||
|
||||
let ctx = items::ItemRunCtx {
|
||||
client,
|
||||
source_id,
|
||||
base_url: &discovery.home_set_url,
|
||||
multiget_batch: config.multiget_batch,
|
||||
dav_connections: config.dav_connections,
|
||||
logger,
|
||||
};
|
||||
for (collection_href, local_id) in &upserted {
|
||||
match (phase.reconcile_items)(conn, &ctx, collection_href, *local_id, &mut item_counts) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"{} {collection_href:?}: items failed: {e}",
|
||||
phase.container_label
|
||||
));
|
||||
item_counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary
|
||||
.per_type
|
||||
.push((phase.container_label, container_counts));
|
||||
summary.per_type.push((phase.item_label, item_counts));
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn run_dry_diff(
|
||||
conn: &rusqlite::Connection,
|
||||
client: &DavClient,
|
||||
discovery: &Discovery,
|
||||
config: &DavImportConfig,
|
||||
logger: Logger,
|
||||
) -> Result<Summary, Error> {
|
||||
use crate::dav::href::join_absolute;
|
||||
use crate::dav::xml;
|
||||
use crate::db::dav_ids;
|
||||
let mut summary = Summary::default();
|
||||
let (container_label, item_label, container_type, item_type) = match config.kind {
|
||||
DavKindArg::Caldav => (
|
||||
"calendar",
|
||||
"calendarevent",
|
||||
dav_ids::CALENDAR,
|
||||
dav_ids::CALENDAR_EVENT,
|
||||
),
|
||||
DavKindArg::Carddav => (
|
||||
"addressbook",
|
||||
"contactcard",
|
||||
dav_ids::ADDRESS_BOOK,
|
||||
dav_ids::CONTACT_CARD,
|
||||
),
|
||||
DavKindArg::Webdav => (
|
||||
"filenode",
|
||||
"filenode",
|
||||
dav_ids::FILE_NODE,
|
||||
dav_ids::FILE_NODE,
|
||||
),
|
||||
};
|
||||
|
||||
let mut container_counts = TypeCounts::default();
|
||||
let mut item_counts = TypeCounts::default();
|
||||
container_counts.created = discovery.collections.len() as u64;
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!(
|
||||
"dry-run: {} new={} (no archive to diff against)",
|
||||
container_label,
|
||||
discovery.collections.len(),
|
||||
);
|
||||
}
|
||||
|
||||
if !matches!(config.kind, DavKindArg::Webdav) {
|
||||
for coll in &discovery.collections {
|
||||
let url = join_absolute(&discovery.home_set_url, coll.href.as_str())
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let ms = client
|
||||
.propfind_responses(&url, 1, &xml::propfind_dav_items(), &url)
|
||||
.map_err(Error::from)?;
|
||||
let new_count = ms
|
||||
.responses
|
||||
.iter()
|
||||
.filter(|r| !r.props.is_collection)
|
||||
.count();
|
||||
item_counts.created += new_count as u64;
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!(" {} items: {new_count}", coll.href.as_str());
|
||||
}
|
||||
}
|
||||
} else if let Some(root) = discovery.collections.first() {
|
||||
let url = join_absolute(&discovery.home_set_url, root.href.as_str())
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let ms = client
|
||||
.propfind_responses(&url, 1, &xml::propfind_webdav_listing(), &url)
|
||||
.map_err(Error::from)?;
|
||||
let new_count = ms
|
||||
.responses
|
||||
.iter()
|
||||
.filter(|r| !r.props.is_collection)
|
||||
.count();
|
||||
item_counts.created += new_count as u64;
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!(" root {} files: {new_count}", root.href.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
let _ = container_type;
|
||||
let _ = item_type;
|
||||
let _ = conn;
|
||||
summary.per_type.push((container_label, container_counts));
|
||||
if !matches!(config.kind, DavKindArg::Webdav) {
|
||||
summary.per_type.push((item_label, item_counts));
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn run_caldav(
|
||||
conn: &mut Connection,
|
||||
client: &DavClient,
|
||||
source_id: i64,
|
||||
discovery: &Discovery,
|
||||
config: &DavImportConfig,
|
||||
logger: Logger,
|
||||
) -> Result<Summary, Error> {
|
||||
run_collection_phase(
|
||||
conn,
|
||||
client,
|
||||
source_id,
|
||||
discovery,
|
||||
config,
|
||||
logger,
|
||||
ItemPhase {
|
||||
container_label: "calendar",
|
||||
item_label: "calendarevent",
|
||||
reconcile_collections: collections::reconcile_calendars,
|
||||
reconcile_items: items::reconcile_calendar_events,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn run_carddav(
|
||||
conn: &mut Connection,
|
||||
client: &DavClient,
|
||||
source_id: i64,
|
||||
discovery: &Discovery,
|
||||
config: &DavImportConfig,
|
||||
logger: Logger,
|
||||
) -> Result<Summary, Error> {
|
||||
run_collection_phase(
|
||||
conn,
|
||||
client,
|
||||
source_id,
|
||||
discovery,
|
||||
config,
|
||||
logger,
|
||||
ItemPhase {
|
||||
container_label: "addressbook",
|
||||
item_label: "contactcard",
|
||||
reconcile_collections: collections::reconcile_address_books,
|
||||
reconcile_items: items::reconcile_contact_cards,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn run_webdav(
|
||||
conn: &mut Connection,
|
||||
client: &DavClient,
|
||||
source_id: i64,
|
||||
discovery: &Discovery,
|
||||
config: &DavImportConfig,
|
||||
logger: Logger,
|
||||
) -> Result<Summary, Error> {
|
||||
let mut summary = Summary::default();
|
||||
let mut file_counts = TypeCounts::default();
|
||||
|
||||
if discovery.collections.is_empty() {
|
||||
summary.per_type.push(("filenode", file_counts));
|
||||
return Ok(summary);
|
||||
}
|
||||
let root = &discovery.collections[0];
|
||||
let ctx = tree::WebDavCtx {
|
||||
client,
|
||||
source_id,
|
||||
base_url: &discovery.home_set_url,
|
||||
dav_connections: config.dav_connections,
|
||||
logger,
|
||||
};
|
||||
tree::reconcile_filenodes(conn, &ctx, root, &mut file_counts)?;
|
||||
|
||||
summary.per_type.push(("filenode", file_counts));
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn map_discovery_error(err: DiscoveryError) -> Error {
|
||||
match err {
|
||||
DiscoveryError::NotFound { url } => Error::Usage(format!(
|
||||
"no DAV collections found under --url {url:?}; \
|
||||
check the URL and that the account has access"
|
||||
)),
|
||||
DiscoveryError::Transport(e) => Error::from(e),
|
||||
DiscoveryError::Parse(e) => {
|
||||
Error::Connection(format!("DAV discovery: malformed multistatus: {e}"))
|
||||
}
|
||||
DiscoveryError::Href(e) => Error::Connection(format!("DAV discovery: bad href: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_gc(conn: &Connection) -> Result<(), Error> {
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
db::blobs::gc_orphan_blobs(&tx).map_err(|e| Error::Partial(e.to_string()))?;
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enforce_tls_policy(url: &str, allow_cleartext: bool) -> Result<(), Error> {
|
||||
let parsed =
|
||||
url::Url::parse(url).map_err(|e| Error::Usage(format!("invalid --url {url:?}: {e}")))?;
|
||||
match parsed.scheme() {
|
||||
"https" => Ok(()),
|
||||
"http" => {
|
||||
if allow_cleartext {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Connection(
|
||||
"http:// URL requires --allow-cleartext".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
other => Err(Error::Usage(format!(
|
||||
"--url scheme must be http or https, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalise_base_url(url: &str) -> String {
|
||||
let parsed = match url::Url::parse(url) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return url.to_owned(),
|
||||
};
|
||||
let scheme = parsed.scheme();
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||
format!("{scheme}://{host}{port}")
|
||||
}
|
||||
|
||||
fn normalise_account_url(url: &str) -> String {
|
||||
let mut parsed = match url::Url::parse(url) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return url.to_owned(),
|
||||
};
|
||||
parsed.set_query(None);
|
||||
parsed.set_fragment(None);
|
||||
let path = parsed.path().to_owned();
|
||||
if !path.ends_with('/') {
|
||||
parsed.set_path(&format!("{path}/"));
|
||||
}
|
||||
parsed.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn https_url_is_accepted_without_allow_cleartext() {
|
||||
assert!(enforce_tls_policy("https://x/", false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_url_requires_allow_cleartext() {
|
||||
let err = enforce_tls_policy("http://x/", false).unwrap_err();
|
||||
assert!(matches!(err, Error::Connection(_)));
|
||||
assert!(enforce_tls_policy("http://x/", true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ftp_url_rejected_as_usage() {
|
||||
let err = enforce_tls_policy("ftp://x/", false).unwrap_err();
|
||||
assert!(matches!(err, Error::Usage(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalise_base_url_strips_path() {
|
||||
assert_eq!(
|
||||
normalise_base_url("https://dav.example.com/cal/"),
|
||||
"https://dav.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalise_base_url_keeps_explicit_port() {
|
||||
assert_eq!(
|
||||
normalise_base_url("https://dav.example.com:8443/cal/"),
|
||||
"https://dav.example.com:8443"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::dav::client::{DavClient, MultiStatus};
|
||||
use crate::dav::href::{Href, join_absolute, normalise};
|
||||
use crate::dav::parse::DavResponse;
|
||||
use crate::dav::xml;
|
||||
use crate::db::dav_ids;
|
||||
use crate::error::Error;
|
||||
use crate::jmap::error::JmapError;
|
||||
use crate::logging::{LEVEL_PROGRESS, Logger};
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::import_jmap::pool::Pool;
|
||||
|
||||
use super::calcard;
|
||||
|
||||
pub struct ItemRunCtx<'a> {
|
||||
pub client: &'a DavClient,
|
||||
pub source_id: i64,
|
||||
pub base_url: &'a str,
|
||||
pub multiget_batch: usize,
|
||||
pub dav_connections: usize,
|
||||
pub logger: Logger,
|
||||
}
|
||||
|
||||
struct PerCollectionCtx<'a> {
|
||||
run: &'a ItemRunCtx<'a>,
|
||||
kind: ItemKind,
|
||||
collection_href: &'a str,
|
||||
container_local_id: i64,
|
||||
absolute_url: &'a str,
|
||||
}
|
||||
|
||||
pub fn reconcile_calendar_events(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
collection_href: &str,
|
||||
calendar_local_id: i64,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let absolute = absolute_collection_url(ctx.base_url, collection_href)?;
|
||||
let server_items = enumerate_items(ctx.client, &absolute)?;
|
||||
let pcc = PerCollectionCtx {
|
||||
run: ctx,
|
||||
kind: ItemKind::CalendarEvent,
|
||||
collection_href,
|
||||
container_local_id: calendar_local_id,
|
||||
absolute_url: &absolute,
|
||||
};
|
||||
reconcile_items_generic(conn, &pcc, &server_items, counts)
|
||||
}
|
||||
|
||||
pub fn reconcile_contact_cards(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
collection_href: &str,
|
||||
address_book_local_id: i64,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let absolute = absolute_collection_url(ctx.base_url, collection_href)?;
|
||||
let server_items = enumerate_items(ctx.client, &absolute)?;
|
||||
let pcc = PerCollectionCtx {
|
||||
run: ctx,
|
||||
kind: ItemKind::ContactCard,
|
||||
collection_href,
|
||||
container_local_id: address_book_local_id,
|
||||
absolute_url: &absolute,
|
||||
};
|
||||
reconcile_items_generic(conn, &pcc, &server_items, counts)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ItemKind {
|
||||
CalendarEvent,
|
||||
ContactCard,
|
||||
}
|
||||
|
||||
impl ItemKind {
|
||||
fn type_name(self) -> &'static str {
|
||||
match self {
|
||||
ItemKind::CalendarEvent => dav_ids::CALENDAR_EVENT,
|
||||
ItemKind::ContactCard => dav_ids::CONTACT_CARD,
|
||||
}
|
||||
}
|
||||
fn data_query(self) -> fn(&[Href]) -> String {
|
||||
match self {
|
||||
ItemKind::CalendarEvent => xml::calendar_multiget,
|
||||
ItemKind::ContactCard => xml::addressbook_multiget,
|
||||
}
|
||||
}
|
||||
fn data_field(self) -> &'static str {
|
||||
match self {
|
||||
ItemKind::CalendarEvent => "calendar_data",
|
||||
ItemKind::ContactCard => "address_data",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ServerItem {
|
||||
href: String,
|
||||
etag: String,
|
||||
}
|
||||
|
||||
fn enumerate_items(client: &DavClient, url: &str) -> Result<Vec<ServerItem>, Error> {
|
||||
let body = xml::propfind_dav_items();
|
||||
let ms = client
|
||||
.propfind_responses(url, 1, &body, url)
|
||||
.map_err(Error::from)?;
|
||||
if ms.status >= 400 {
|
||||
return Err(Error::Partial(format!(
|
||||
"enumerate {url}: http {}",
|
||||
ms.status
|
||||
)));
|
||||
}
|
||||
let self_href = normalise(url, "")
|
||||
.map(|h| h.into_string())
|
||||
.unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
for r in ms.responses {
|
||||
if r.href.as_str() == self_href {
|
||||
continue;
|
||||
}
|
||||
if r.props.is_collection {
|
||||
continue;
|
||||
}
|
||||
out.push(ServerItem {
|
||||
href: r.href.into_string(),
|
||||
etag: r.props.etag.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct MultigetJob {
|
||||
body: String,
|
||||
hrefs: Vec<String>,
|
||||
}
|
||||
|
||||
struct MultigetReply {
|
||||
hrefs: Vec<String>,
|
||||
result: Result<MultiStatus, JmapError>,
|
||||
}
|
||||
|
||||
struct GetJob {
|
||||
url: String,
|
||||
item: ServerItem,
|
||||
}
|
||||
|
||||
struct GetReply {
|
||||
item: ServerItem,
|
||||
result: Result<GetBody, JmapError>,
|
||||
}
|
||||
|
||||
struct GetBody {
|
||||
status: u16,
|
||||
bytes: Vec<u8>,
|
||||
etag: Option<String>,
|
||||
}
|
||||
|
||||
fn reconcile_items_generic(
|
||||
conn: &mut Connection,
|
||||
pcc: &PerCollectionCtx<'_>,
|
||||
server_items: &[ServerItem],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let type_name = pcc.kind.type_name();
|
||||
let local_rows =
|
||||
dav_ids::items_in_collection(conn, pcc.run.source_id, type_name, pcc.collection_href)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let local_map: HashMap<String, (String, i64)> = local_rows
|
||||
.iter()
|
||||
.map(|r| (r.item_href.clone(), (r.etag.clone(), r.local_id)))
|
||||
.collect();
|
||||
let server_set: HashSet<&str> = server_items.iter().map(|s| s.href.as_str()).collect();
|
||||
|
||||
let mut to_fetch: Vec<ServerItem> = Vec::new();
|
||||
for s in server_items {
|
||||
match local_map.get(&s.href) {
|
||||
None => to_fetch.push(s.clone()),
|
||||
Some((local_etag, _)) => {
|
||||
if s.etag.is_empty() || s.etag != *local_etag {
|
||||
to_fetch.push(s.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let vanished: Vec<String> = local_map
|
||||
.keys()
|
||||
.filter(|h| !server_set.contains(h.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if !to_fetch.is_empty() {
|
||||
let fallback = run_multiget_pool(conn, pcc, &to_fetch, &local_map, counts)?;
|
||||
if !fallback.is_empty() {
|
||||
run_get_pool(conn, pcc, &fallback, &local_map, counts)?;
|
||||
}
|
||||
}
|
||||
|
||||
for href in &vanished {
|
||||
let local_id = match local_map.get(href) {
|
||||
Some((_, id)) => *id,
|
||||
None => continue,
|
||||
};
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let table = match pcc.kind {
|
||||
ItemKind::CalendarEvent => "calendar_events",
|
||||
ItemKind::ContactCard => "contact_cards",
|
||||
};
|
||||
tx.execute(
|
||||
&format!("DELETE FROM {table} WHERE id = ?1"),
|
||||
params![local_id],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
dav_ids::delete_item(&tx, pcc.run.source_id, type_name, href)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.deleted += 1;
|
||||
}
|
||||
|
||||
if pcc.run.logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"items in {}: fetched={} deleted={} failed={}",
|
||||
pcc.collection_href, counts.fetched, counts.deleted, counts.failed
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_multiget_pool(
|
||||
conn: &mut Connection,
|
||||
pcc: &PerCollectionCtx<'_>,
|
||||
to_fetch: &[ServerItem],
|
||||
local_map: &HashMap<String, (String, i64)>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<Vec<ServerItem>, Error> {
|
||||
let logger = pcc.run.logger;
|
||||
let absolute_url = pcc.absolute_url;
|
||||
let batch_size = pcc.run.multiget_batch.max(1);
|
||||
let workers = pcc.run.dav_connections.clamp(1, 8);
|
||||
let by_href: HashMap<String, ServerItem> = to_fetch
|
||||
.iter()
|
||||
.map(|s| (s.href.clone(), s.clone()))
|
||||
.collect();
|
||||
let client_for_pool = pcc.run.client.clone();
|
||||
let url_for_pool = absolute_url.to_owned();
|
||||
let pool: Pool<MultigetJob, MultigetReply> = Pool::new(workers, move |job: MultigetJob| {
|
||||
let result = client_for_pool.report_responses(&url_for_pool, 1, &job.body, &url_for_pool);
|
||||
MultigetReply {
|
||||
hrefs: job.hrefs,
|
||||
result,
|
||||
}
|
||||
});
|
||||
let mut submitted: usize = 0;
|
||||
for chunk in to_fetch.chunks(batch_size) {
|
||||
let hrefs: Vec<Href> = chunk
|
||||
.iter()
|
||||
.map(|s| Href::from_normalised(s.href.clone()))
|
||||
.collect();
|
||||
let chunk_hrefs: Vec<String> = chunk.iter().map(|s| s.href.clone()).collect();
|
||||
pool.submit(MultigetJob {
|
||||
body: (pcc.kind.data_query())(&hrefs),
|
||||
hrefs: chunk_hrefs,
|
||||
});
|
||||
submitted += 1;
|
||||
}
|
||||
|
||||
let mut fallback: Vec<ServerItem> = Vec::new();
|
||||
for _ in 0..submitted {
|
||||
let reply = match pool.results().recv() {
|
||||
Ok(r) => r,
|
||||
Err(_) => break,
|
||||
};
|
||||
match reply.result {
|
||||
Err(JmapError::HttpStatus { status, .. }) if status == 405 || status == 501 => {
|
||||
logger.warn(&format!(
|
||||
"multiget {absolute_url} returned {status}; server may not implement multiget, falling back to per-item GET"
|
||||
));
|
||||
fallback.extend(reply.hrefs.iter().filter_map(|h| by_href.get(h).cloned()));
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"multiget {absolute_url}: {e}; falling back to per-item GET"
|
||||
));
|
||||
fallback.extend(reply.hrefs.iter().filter_map(|h| by_href.get(h).cloned()));
|
||||
}
|
||||
Ok(ms) if ms.status >= 400 => {
|
||||
logger.warn(&format!(
|
||||
"multiget {absolute_url} returned {}; chunk failed",
|
||||
ms.status
|
||||
));
|
||||
counts.failed += reply.hrefs.len() as u64;
|
||||
}
|
||||
Ok(ms) => {
|
||||
let requested: HashSet<&str> = reply.hrefs.iter().map(String::as_str).collect();
|
||||
let mut tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let mut pending: usize = 0;
|
||||
for r in ms.responses {
|
||||
if !requested.contains(r.href.as_str()) {
|
||||
logger.warn(&format!(
|
||||
"multiget {absolute_url}: server returned unsolicited href {}; skipping",
|
||||
r.href.as_str()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
handle_multiget_response(&tx, pcc, &r, local_map, counts);
|
||||
pending += 1;
|
||||
if pending >= COMMIT_BATCH {
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
pending = 0;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(pool);
|
||||
Ok(fallback)
|
||||
}
|
||||
|
||||
fn run_get_pool(
|
||||
conn: &mut Connection,
|
||||
pcc: &PerCollectionCtx<'_>,
|
||||
items: &[ServerItem],
|
||||
local_map: &HashMap<String, (String, i64)>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let logger = pcc.run.logger;
|
||||
let absolute_url = pcc.absolute_url;
|
||||
let workers = pcc.run.dav_connections.clamp(1, 8);
|
||||
let client_for_pool = pcc.run.client.clone();
|
||||
let pool: Pool<GetJob, GetReply> = Pool::new(workers, move |job: GetJob| {
|
||||
let r = client_for_pool.get(&job.url).map(|resp| GetBody {
|
||||
status: resp.status,
|
||||
bytes: resp.body,
|
||||
etag: resp.etag,
|
||||
});
|
||||
GetReply {
|
||||
item: job.item,
|
||||
result: r,
|
||||
}
|
||||
});
|
||||
|
||||
let mut submitted: usize = 0;
|
||||
for item in items {
|
||||
let url = match absolute_item_url(absolute_url, &item.href) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
logger.warn(&format!("item url {}: {e}", item.href));
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
pool.submit(GetJob {
|
||||
url,
|
||||
item: item.clone(),
|
||||
});
|
||||
submitted += 1;
|
||||
}
|
||||
|
||||
let mut tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let mut pending: usize = 0;
|
||||
for _ in 0..submitted {
|
||||
let reply = match pool.results().recv() {
|
||||
Ok(r) => r,
|
||||
Err(_) => break,
|
||||
};
|
||||
match reply.result {
|
||||
Err(e) => {
|
||||
logger.warn(&format!("GET {}: {e}", reply.item.href));
|
||||
counts.failed += 1;
|
||||
}
|
||||
Ok(body) if body.status == 404 || body.status == 410 => {}
|
||||
Ok(body) if body.status >= 400 => {
|
||||
logger.warn(&format!("GET {}: http {}", reply.item.href, body.status));
|
||||
counts.failed += 1;
|
||||
}
|
||||
Ok(body) => {
|
||||
let etag = body.etag.unwrap_or_else(|| reply.item.etag.clone());
|
||||
let raw = match std::str::from_utf8(&body.bytes) {
|
||||
Ok(s) => s.to_owned(),
|
||||
Err(e) => {
|
||||
logger.warn(&format!("GET {}: non-utf8 body: {e}", reply.item.href));
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) =
|
||||
insert_or_update_tx(&tx, pcc, &reply.item.href, &etag, &raw, local_map)
|
||||
{
|
||||
logger.warn(&format!("item {}: {e}", reply.item.href));
|
||||
counts.failed += 1;
|
||||
} else {
|
||||
counts.fetched += 1;
|
||||
pending += 1;
|
||||
if pending >= COMMIT_BATCH {
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
pending = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
drop(pool);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const COMMIT_BATCH: usize = 64;
|
||||
|
||||
fn handle_multiget_response(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
pcc: &PerCollectionCtx<'_>,
|
||||
response: &DavResponse,
|
||||
local_map: &HashMap<String, (String, i64)>,
|
||||
counts: &mut TypeCounts,
|
||||
) {
|
||||
let logger = pcc.run.logger;
|
||||
if response_indicates_vanished(response) {
|
||||
return;
|
||||
}
|
||||
let item_href = response.href.as_str().to_owned();
|
||||
let etag = response.props.etag.clone().unwrap_or_default();
|
||||
let data = match pcc.kind {
|
||||
ItemKind::CalendarEvent => response.props.calendar_data.as_deref(),
|
||||
ItemKind::ContactCard => response.props.address_data.as_deref(),
|
||||
};
|
||||
let Some(raw) = data else {
|
||||
logger.warn(&format!(
|
||||
"multiget {item_href}: missing {} payload",
|
||||
pcc.kind.data_field()
|
||||
));
|
||||
counts.failed += 1;
|
||||
return;
|
||||
};
|
||||
if let Err(e) = insert_or_update_tx(tx, pcc, &item_href, &etag, raw, local_map) {
|
||||
logger.warn(&format!("item {item_href}: {e}"));
|
||||
counts.failed += 1;
|
||||
} else {
|
||||
counts.fetched += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_or_update_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
pcc: &PerCollectionCtx<'_>,
|
||||
item_href: &str,
|
||||
etag: &str,
|
||||
raw: &str,
|
||||
local_map: &HashMap<String, (String, i64)>,
|
||||
) -> Result<(), Error> {
|
||||
let existing = local_map.get(item_href).map(|(_, id)| *id);
|
||||
match pcc.kind {
|
||||
ItemKind::CalendarEvent => insert_or_update_event(
|
||||
tx,
|
||||
pcc.container_local_id,
|
||||
existing,
|
||||
raw,
|
||||
item_href,
|
||||
pcc.run.logger,
|
||||
)?,
|
||||
ItemKind::ContactCard => {
|
||||
insert_or_update_card(tx, pcc.container_local_id, existing, raw, item_href)?
|
||||
}
|
||||
};
|
||||
if existing.is_none() {
|
||||
let new_local = tx.last_insert_rowid();
|
||||
dav_ids::insert(
|
||||
tx,
|
||||
pcc.run.source_id,
|
||||
pcc.kind.type_name(),
|
||||
pcc.collection_href,
|
||||
item_href,
|
||||
etag,
|
||||
new_local,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
} else {
|
||||
dav_ids::update_etag(tx, pcc.run.source_id, pcc.kind.type_name(), item_href, etag)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_or_update_event(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
calendar_local: i64,
|
||||
existing: Option<i64>,
|
||||
raw: &str,
|
||||
item_href: &str,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let entries = calcard::ical_to_jscalendar_entries(raw)
|
||||
.map_err(|e| Error::Partial(format!("iCalendar parse: {e}")))?;
|
||||
if entries.len() > 1 && logger.enabled(crate::logging::LEVEL_DEFAULT) {
|
||||
logger.warn(&format!(
|
||||
"{item_href}: iCalendar resource has {} entries; storing the first ({}), \
|
||||
others dropped (recurrence overrides on the master are preserved)",
|
||||
entries.len(),
|
||||
entries[0].data_type.as_column(),
|
||||
));
|
||||
}
|
||||
let mut first = entries
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| Error::Partial("iCalendar contained no parseable entries".to_owned()))?;
|
||||
let (is_draft, use_default_alerts, _uid) =
|
||||
calcard::strip_extracted_fields_from_event(&mut first.data);
|
||||
let data_type = first.data_type.as_column();
|
||||
let calendar_ids = format!("[{calendar_local}]");
|
||||
if let Some(local) = existing {
|
||||
tx.execute(
|
||||
"UPDATE calendar_events SET calendar_ids = ?1, is_draft = ?2,
|
||||
use_default_alerts = ?3, data = ?4, data_type = ?5
|
||||
WHERE id = ?6",
|
||||
params![
|
||||
calendar_ids,
|
||||
is_draft as i64,
|
||||
use_default_alerts as i64,
|
||||
first.data.to_string(),
|
||||
data_type,
|
||||
local,
|
||||
],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO calendar_events (calendar_ids, is_draft, use_default_alerts, data, data_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
calendar_ids,
|
||||
is_draft as i64,
|
||||
use_default_alerts as i64,
|
||||
first.data.to_string(),
|
||||
data_type,
|
||||
],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_or_update_card(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
address_book_local: i64,
|
||||
existing: Option<i64>,
|
||||
raw: &str,
|
||||
item_href: &str,
|
||||
) -> Result<(), Error> {
|
||||
let card = calcard::vcard_to_jscontact(raw, item_href)
|
||||
.map_err(|e| Error::Partial(format!("vCard parse: {e}")))?;
|
||||
let address_book_ids = format!("[{address_book_local}]");
|
||||
if let Some(local) = existing {
|
||||
tx.execute(
|
||||
"UPDATE contact_cards SET uid = ?1, address_book_ids = ?2, data = ?3
|
||||
WHERE id = ?4",
|
||||
params![card.uid, address_book_ids, card.data.to_string(), local],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO contact_cards (uid, address_book_ids, data) VALUES (?1, ?2, ?3)",
|
||||
params![card.uid, address_book_ids, card.data.to_string()],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn response_indicates_vanished(response: &DavResponse) -> bool {
|
||||
if let Some(status) = response.status
|
||||
&& (status == 404 || status == 410)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
response
|
||||
.propstat_errors
|
||||
.iter()
|
||||
.any(|s| *s == 404 || *s == 410)
|
||||
}
|
||||
|
||||
fn absolute_collection_url(base_url: &str, href: &str) -> Result<String, Error> {
|
||||
join_absolute(base_url, href).map_err(|e| Error::Partial(e.to_string()))
|
||||
}
|
||||
|
||||
fn absolute_item_url(collection_url: &str, item_href: &str) -> Result<String, Error> {
|
||||
join_absolute(collection_url, item_href).map_err(|e| Error::Partial(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absolute_collection_url_joins_path() {
|
||||
let u = absolute_collection_url("https://x/dav/", "/dav/cal/u/d/").unwrap();
|
||||
assert_eq!(u, "https://x/dav/cal/u/d/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_item_url_joins_relative() {
|
||||
let u = absolute_item_url("https://x/dav/cal/u/d/", "/dav/cal/u/d/event.ics").unwrap();
|
||||
assert_eq!(u, "https://x/dav/cal/u/d/event.ics");
|
||||
}
|
||||
|
||||
fn response(status: Option<u16>, propstat_errors: Vec<u16>) -> DavResponse {
|
||||
DavResponse {
|
||||
href: Href::from_normalised("/dav/cal/u/d/e.ics".to_owned()),
|
||||
status,
|
||||
props: crate::dav::parse::ResourceProps::default(),
|
||||
propstat_errors,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_level_404_is_vanished() {
|
||||
assert!(response_indicates_vanished(&response(Some(404), vec![])));
|
||||
assert!(response_indicates_vanished(&response(Some(410), vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_propstat_404_or_410_is_vanished() {
|
||||
assert!(response_indicates_vanished(&response(None, vec![404])));
|
||||
assert!(response_indicates_vanished(&response(None, vec![200, 410])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_propstat_errors_are_not_vanished() {
|
||||
assert!(!response_indicates_vanished(&response(None, vec![403])));
|
||||
assert!(!response_indicates_vanished(&response(None, vec![])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::io::Read;
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::dav::client::DavClient;
|
||||
use crate::dav::discover::DiscoveredCollection;
|
||||
use crate::dav::href::{Href, join_absolute, last_path_component};
|
||||
use crate::dav::parse::DavResponse;
|
||||
use crate::dav::xml;
|
||||
use crate::db;
|
||||
use crate::db::dav_ids;
|
||||
use crate::error::Error;
|
||||
use crate::jmap::error::JmapError;
|
||||
use crate::logging::{LEVEL_PROGRESS, Logger};
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::import_jmap::pool::Pool;
|
||||
|
||||
struct FilePlan {
|
||||
item_href: String,
|
||||
parent_href: String,
|
||||
parent_local: i64,
|
||||
existing_local: Option<i64>,
|
||||
propfind_etag: String,
|
||||
propfind_content_type: Option<String>,
|
||||
propfind_last_modified: Option<String>,
|
||||
propfind_creation_date: Option<String>,
|
||||
propfind_displayname: Option<String>,
|
||||
url: String,
|
||||
}
|
||||
|
||||
struct FileFetch {
|
||||
plan: FilePlan,
|
||||
}
|
||||
|
||||
struct FileFetched {
|
||||
plan: FilePlan,
|
||||
result: Result<FetchedBody, JmapError>,
|
||||
}
|
||||
|
||||
struct FetchedBody {
|
||||
status: u16,
|
||||
bytes: Vec<u8>,
|
||||
etag: Option<String>,
|
||||
content_type: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
}
|
||||
|
||||
pub struct WebDavCtx<'a> {
|
||||
pub client: &'a DavClient,
|
||||
pub source_id: i64,
|
||||
pub base_url: &'a str,
|
||||
pub dav_connections: usize,
|
||||
pub logger: Logger,
|
||||
}
|
||||
|
||||
pub fn reconcile_filenodes(
|
||||
conn: &mut Connection,
|
||||
ctx: &WebDavCtx<'_>,
|
||||
root: &DiscoveredCollection,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let client = ctx.client;
|
||||
let source_id = ctx.source_id;
|
||||
let base_url = ctx.base_url;
|
||||
let dav_connections = ctx.dav_connections;
|
||||
let logger = ctx.logger;
|
||||
let absolute_root = absolute(base_url, root.href.as_str())?;
|
||||
let root_local = super::collections::upsert_root_directory(conn, source_id, root)?;
|
||||
|
||||
let known: Vec<(String, i64)> =
|
||||
dav_ids::collections_of_type(conn, source_id, dav_ids::FILE_NODE)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
seen.insert(root.href.as_str().to_owned());
|
||||
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
visited.insert(root.href.as_str().to_owned());
|
||||
let mut queue: VecDeque<(String, String, i64)> = VecDeque::new();
|
||||
queue.push_back((absolute_root, root.href.as_str().to_owned(), root_local));
|
||||
|
||||
let mut file_plans: Vec<FilePlan> = Vec::new();
|
||||
|
||||
while let Some((url, parent_href, parent_local)) = queue.pop_front() {
|
||||
match walk_one(
|
||||
conn,
|
||||
client,
|
||||
source_id,
|
||||
WalkPos {
|
||||
url: &url,
|
||||
parent_href: &parent_href,
|
||||
parent_local,
|
||||
},
|
||||
WalkState {
|
||||
counts,
|
||||
seen: &mut seen,
|
||||
file_plans: &mut file_plans,
|
||||
},
|
||||
logger,
|
||||
) {
|
||||
Ok(children) => {
|
||||
for (child_url, child_href, child_local) in children {
|
||||
if visited.insert(child_href.clone()) {
|
||||
queue.push_back((child_url, child_href, child_local));
|
||||
} else {
|
||||
logger.warn(&format!("cycle detected at {child_href}; not recursing"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!("PROPFIND {url}: {e}"));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetch_files_parallel(
|
||||
conn,
|
||||
client,
|
||||
source_id,
|
||||
dav_connections,
|
||||
file_plans,
|
||||
counts,
|
||||
logger,
|
||||
)?;
|
||||
|
||||
delete_vanished(conn, source_id, &known, &seen, counts, logger)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_files_parallel(
|
||||
conn: &mut Connection,
|
||||
client: &DavClient,
|
||||
source_id: i64,
|
||||
dav_connections: usize,
|
||||
plans: Vec<FilePlan>,
|
||||
counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
if plans.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let workers = dav_connections.clamp(1, 8);
|
||||
let client_for_pool = client.clone();
|
||||
let pool: Pool<FileFetch, FileFetched> = Pool::new(workers, move |job: FileFetch| {
|
||||
let result = match client_for_pool.get_stream(&job.plan.url) {
|
||||
Ok(mut s) => {
|
||||
let mut bytes = Vec::new();
|
||||
match s.read_to_end(&mut bytes) {
|
||||
Ok(_) => Ok(FetchedBody {
|
||||
status: s.status,
|
||||
bytes,
|
||||
etag: s.etag,
|
||||
content_type: s.content_type,
|
||||
last_modified: s.last_modified,
|
||||
}),
|
||||
Err(e) => Err(JmapError::Transport(format!("read body: {e}"))),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
FileFetched {
|
||||
plan: job.plan,
|
||||
result,
|
||||
}
|
||||
});
|
||||
|
||||
let mut submitted: usize = 0;
|
||||
for plan in plans {
|
||||
pool.submit(FileFetch { plan });
|
||||
submitted += 1;
|
||||
}
|
||||
|
||||
const FILE_COMMIT_BATCH: usize = 16;
|
||||
let mut tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let mut pending: usize = 0;
|
||||
for _ in 0..submitted {
|
||||
let fetched = match pool.results().recv() {
|
||||
Ok(r) => r,
|
||||
Err(_) => break,
|
||||
};
|
||||
match fetched.result {
|
||||
Err(e) => {
|
||||
logger.warn(&format!("file {}: {e}", fetched.plan.item_href));
|
||||
counts.failed += 1;
|
||||
}
|
||||
Ok(body) if body.status == 404 || body.status == 410 => {}
|
||||
Ok(body) if body.status >= 400 => {
|
||||
logger.warn(&format!(
|
||||
"file {}: http {}",
|
||||
fetched.plan.item_href, body.status
|
||||
));
|
||||
counts.failed += 1;
|
||||
}
|
||||
Ok(body) => match commit_file(&tx, source_id, &fetched.plan, body, logger) {
|
||||
Ok(()) => {
|
||||
counts.fetched += 1;
|
||||
pending += 1;
|
||||
if pending >= FILE_COMMIT_BATCH {
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
pending = 0;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!("file {}: {e}", fetched.plan.item_href));
|
||||
counts.failed += 1;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
drop(pool);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commit_file(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
source_id: i64,
|
||||
plan: &FilePlan,
|
||||
body: FetchedBody,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let etag = body
|
||||
.etag
|
||||
.clone()
|
||||
.unwrap_or_else(|| plan.propfind_etag.clone());
|
||||
let content_type = body
|
||||
.content_type
|
||||
.or_else(|| plan.propfind_content_type.clone());
|
||||
let last_modified = normalise_dav_date(
|
||||
&body
|
||||
.last_modified
|
||||
.or_else(|| plan.propfind_last_modified.clone()),
|
||||
);
|
||||
let created = format_or_now(&plan.propfind_creation_date)?;
|
||||
let name = display_or_basename(plan, logger);
|
||||
|
||||
let blob_local =
|
||||
db::blobs::intern_blob(tx, &body.bytes).map_err(|e| Error::Partial(e.to_string()))?;
|
||||
if let Some(local) = plan.existing_local {
|
||||
tx.execute(
|
||||
"UPDATE file_nodes SET parent_id = ?1, node_type = 'file', blob_id = ?2,
|
||||
target = NULL, name = ?3, media_type = ?4,
|
||||
created = ?5, modified = ?6
|
||||
WHERE id = ?7",
|
||||
params![
|
||||
plan.parent_local,
|
||||
blob_local,
|
||||
name,
|
||||
content_type,
|
||||
created,
|
||||
last_modified,
|
||||
local,
|
||||
],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
dav_ids::update_etag(tx, source_id, dav_ids::FILE_NODE, &plan.item_href, &etag)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO file_nodes (parent_id, node_type, blob_id, target, name, media_type,
|
||||
created, modified, is_subscribed, role)
|
||||
VALUES (?1, 'file', ?2, NULL, ?3, ?4, ?5, ?6, 1, NULL)",
|
||||
params![
|
||||
plan.parent_local,
|
||||
blob_local,
|
||||
name,
|
||||
content_type,
|
||||
created,
|
||||
last_modified,
|
||||
],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let local = tx.last_insert_rowid();
|
||||
dav_ids::insert(
|
||||
tx,
|
||||
source_id,
|
||||
dav_ids::FILE_NODE,
|
||||
&plan.parent_href,
|
||||
&plan.item_href,
|
||||
&etag,
|
||||
local,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_or_basename(plan: &FilePlan, logger: Logger) -> String {
|
||||
let href = Href::from_normalised(plan.item_href.clone());
|
||||
let basename = decoded_basename(&href);
|
||||
if let Some(n) = &plan.propfind_displayname
|
||||
&& !n.trim().is_empty()
|
||||
&& n.trim() != basename
|
||||
&& logger.enabled(crate::logging::LEVEL_BODIES)
|
||||
{
|
||||
eprintln!(
|
||||
"webdav: {} displayname={:?} differs from basename={basename:?}; using basename",
|
||||
plan.item_href, n
|
||||
);
|
||||
}
|
||||
basename
|
||||
}
|
||||
|
||||
fn delete_vanished(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
known: &[(String, i64)],
|
||||
seen: &HashSet<String>,
|
||||
counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let mut vanished: Vec<(String, i64)> = known
|
||||
.iter()
|
||||
.filter(|(href, _)| !seen.contains(href))
|
||||
.cloned()
|
||||
.collect();
|
||||
if vanished.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
vanished.sort_by(|a, b| {
|
||||
let depth_a = a.0.matches('/').count();
|
||||
let depth_b = b.0.matches('/').count();
|
||||
depth_b.cmp(&depth_a).then_with(|| a.0.cmp(&b.0))
|
||||
});
|
||||
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
for (href, local_id) in &vanished {
|
||||
if let Err(e) = tx.execute("DELETE FROM file_nodes WHERE id = ?1", params![local_id]) {
|
||||
logger.warn(&format!(
|
||||
"vanished file_node {href:?} delete failed: {e}; skipping"
|
||||
));
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = dav_ids::delete_item(&tx, source_id, dav_ids::FILE_NODE, href) {
|
||||
logger.warn(&format!(
|
||||
"vanished file_node {href:?} sync_id delete failed: {e}"
|
||||
));
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
counts.deleted += 1;
|
||||
}
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!("webdav: deleted {} vanished file_nodes", vanished.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct WalkPos<'a> {
|
||||
url: &'a str,
|
||||
parent_href: &'a str,
|
||||
parent_local: i64,
|
||||
}
|
||||
|
||||
struct WalkState<'a> {
|
||||
counts: &'a mut TypeCounts,
|
||||
seen: &'a mut HashSet<String>,
|
||||
file_plans: &'a mut Vec<FilePlan>,
|
||||
}
|
||||
|
||||
fn walk_one(
|
||||
conn: &mut Connection,
|
||||
client: &DavClient,
|
||||
source_id: i64,
|
||||
pos: WalkPos<'_>,
|
||||
state: WalkState<'_>,
|
||||
logger: Logger,
|
||||
) -> Result<Vec<(String, String, i64)>, Error> {
|
||||
let url = pos.url;
|
||||
let parent_href = pos.parent_href;
|
||||
let parent_local = pos.parent_local;
|
||||
let counts = state.counts;
|
||||
let seen = state.seen;
|
||||
let file_plans = state.file_plans;
|
||||
let body = xml::propfind_webdav_listing();
|
||||
let ms = client
|
||||
.propfind_responses(url, 1, &body, url)
|
||||
.map_err(Error::from)?;
|
||||
if ms.status >= 400 {
|
||||
return Err(Error::Partial(format!("http {}", ms.status)));
|
||||
}
|
||||
let parent_norm = crate::dav::href::normalise(url, "")
|
||||
.map(|h| h.into_string())
|
||||
.unwrap_or_default();
|
||||
let mut children = Vec::new();
|
||||
for r in ms.responses {
|
||||
if r.href.as_str() == parent_norm || r.href.as_str() == parent_href {
|
||||
continue;
|
||||
}
|
||||
seen.insert(r.href.as_str().to_owned());
|
||||
if r.props.is_collection {
|
||||
let local = upsert_directory(conn, source_id, &r, parent_local, counts)?;
|
||||
let abs = absolute(url, r.href.as_str())?;
|
||||
children.push((abs, r.href.as_str().to_owned(), local));
|
||||
} else {
|
||||
match plan_file(conn, source_id, &r, url, parent_local, parent_href) {
|
||||
Ok(Some(plan)) => file_plans.push(plan),
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
logger.warn(&format!("file {}: {e}", r.href.as_str()));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"dir {url}: queued {} files (fetched-so-far={} failed-so-far={})",
|
||||
file_plans.len(),
|
||||
counts.fetched,
|
||||
counts.failed
|
||||
);
|
||||
}
|
||||
Ok(children)
|
||||
}
|
||||
|
||||
fn plan_file(
|
||||
conn: &Connection,
|
||||
source_id: i64,
|
||||
response: &DavResponse,
|
||||
collection_url: &str,
|
||||
parent_local: i64,
|
||||
parent_href: &str,
|
||||
) -> Result<Option<FilePlan>, Error> {
|
||||
let item_href = response.href.as_str().to_owned();
|
||||
let propfind_etag = response.props.etag.clone().unwrap_or_default();
|
||||
let existing = dav_ids::local_for_item(conn, source_id, dav_ids::FILE_NODE, &item_href)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
if let Some(local) = existing {
|
||||
let stored_etag: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT etag FROM sync_id_dav
|
||||
WHERE source_id = ?1 AND type_name = ?2 AND item_href = ?3",
|
||||
params![source_id, dav_ids::FILE_NODE, item_href],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
if let Some(stored) = stored_etag.as_deref()
|
||||
&& !propfind_etag.is_empty()
|
||||
&& stored == propfind_etag
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let url = absolute(collection_url, &item_href)?;
|
||||
Ok(Some(FilePlan {
|
||||
item_href,
|
||||
parent_href: parent_href.to_owned(),
|
||||
parent_local,
|
||||
existing_local: Some(local),
|
||||
propfind_etag,
|
||||
propfind_content_type: response.props.content_type.clone(),
|
||||
propfind_last_modified: response.props.last_modified.clone(),
|
||||
propfind_creation_date: response.props.creation_date.clone(),
|
||||
propfind_displayname: response.props.displayname.clone(),
|
||||
url,
|
||||
}))
|
||||
} else {
|
||||
let url = absolute(collection_url, &item_href)?;
|
||||
Ok(Some(FilePlan {
|
||||
item_href,
|
||||
parent_href: parent_href.to_owned(),
|
||||
parent_local,
|
||||
existing_local: None,
|
||||
propfind_etag,
|
||||
propfind_content_type: response.props.content_type.clone(),
|
||||
propfind_last_modified: response.props.last_modified.clone(),
|
||||
propfind_creation_date: response.props.creation_date.clone(),
|
||||
propfind_displayname: response.props.displayname.clone(),
|
||||
url,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_directory(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
response: &DavResponse,
|
||||
parent_local: i64,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<i64, Error> {
|
||||
let item_href = response.href.as_str().to_owned();
|
||||
let name = display_or_path(&response.props.displayname, &response.href);
|
||||
let modified = normalise_dav_date(&response.props.last_modified);
|
||||
let existing = dav_ids::local_for_item(conn, source_id, dav_ids::FILE_NODE, &item_href)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
if let Some(local) = existing {
|
||||
tx.execute(
|
||||
"UPDATE file_nodes SET parent_id = ?1, name = ?2, modified = ?3
|
||||
WHERE id = ?4",
|
||||
params![parent_local, name, modified, local],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.fetched += 1;
|
||||
Ok(local)
|
||||
} else {
|
||||
let created = format_or_now(&response.props.creation_date)?;
|
||||
tx.execute(
|
||||
"INSERT INTO file_nodes (parent_id, node_type, blob_id, target, name, media_type,
|
||||
created, modified, is_subscribed, role)
|
||||
VALUES (?1, 'directory', NULL, NULL, ?2, NULL, ?3, ?4, 1, NULL)",
|
||||
params![parent_local, name, created, modified],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let local = tx.last_insert_rowid();
|
||||
let parent_href = parent_href_string(&response.href);
|
||||
dav_ids::insert(
|
||||
&tx,
|
||||
source_id,
|
||||
dav_ids::FILE_NODE,
|
||||
&parent_href,
|
||||
&item_href,
|
||||
"",
|
||||
local,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.created += 1;
|
||||
Ok(local)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parent_href_string(href: &Href) -> String {
|
||||
let s = href.as_str();
|
||||
let trimmed = s.trim_end_matches('/');
|
||||
if let Some(slash) = trimmed.rfind('/') {
|
||||
s[..=slash].to_owned()
|
||||
} else {
|
||||
"/".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn absolute(base: &str, href: &str) -> Result<String, Error> {
|
||||
join_absolute(base, href).map_err(|e| Error::Partial(e.to_string()))
|
||||
}
|
||||
|
||||
fn display_or_path(name: &Option<String>, href: &Href) -> String {
|
||||
if let Some(n) = name
|
||||
&& !n.trim().is_empty()
|
||||
{
|
||||
return n.clone();
|
||||
}
|
||||
decoded_basename(href)
|
||||
}
|
||||
|
||||
fn decoded_basename(href: &Href) -> String {
|
||||
let c = last_path_component(href);
|
||||
if c.is_empty() { "_".to_owned() } else { c }
|
||||
}
|
||||
|
||||
fn normalise_dav_date(input: &Option<String>) -> Option<String> {
|
||||
let raw = input.as_deref()?.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(t) = time::OffsetDateTime::parse(raw, &Rfc3339) {
|
||||
return t.format(&Rfc3339).ok();
|
||||
}
|
||||
let imf_fixdate = time::macros::format_description!(
|
||||
"[weekday repr:short], [day] [month repr:short] [year] \
|
||||
[hour]:[minute]:[second] GMT"
|
||||
);
|
||||
if let Ok(p) = time::PrimitiveDateTime::parse(raw, imf_fixdate) {
|
||||
return p.assume_utc().format(&Rfc3339).ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn format_or_now(input: &Option<String>) -> Result<String, Error> {
|
||||
if let Some(s) = normalise_dav_date(input) {
|
||||
return Ok(s);
|
||||
}
|
||||
time::OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.map_err(|e| Error::Partial(format!("clock: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parent_href_string_of_root_is_root() {
|
||||
let h = Href::from_normalised("/".to_owned());
|
||||
assert_eq!(parent_href_string(&h), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_href_string_of_dir_strips_trailing_segment() {
|
||||
let h = Href::from_normalised("/dav/file/u/work/".to_owned());
|
||||
assert_eq!(parent_href_string(&h), "/dav/file/u/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_basename_handles_percent_encoding() {
|
||||
let h = Href::from_normalised("/dav/file/u/work/note%201.txt".to_owned());
|
||||
assert_eq!(decoded_basename(&h), "note 1.txt");
|
||||
}
|
||||
|
||||
fn plan_with(displayname: Option<&str>, href: &str) -> FilePlan {
|
||||
FilePlan {
|
||||
item_href: href.to_owned(),
|
||||
parent_href: "/dav/file/u/".to_owned(),
|
||||
parent_local: 1,
|
||||
existing_local: None,
|
||||
propfind_etag: String::new(),
|
||||
propfind_content_type: None,
|
||||
propfind_last_modified: None,
|
||||
propfind_creation_date: None,
|
||||
propfind_displayname: displayname.map(str::to_owned),
|
||||
url: format!("https://x{href}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_or_basename_prefers_url_basename_over_displayname() {
|
||||
let plan = plan_with(Some("Pretty Name.txt"), "/dav/file/u/photo.jpg");
|
||||
let logger = Logger::from_flags(false, 0);
|
||||
assert_eq!(display_or_basename(&plan, logger), "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_or_basename_returns_basename_when_no_displayname() {
|
||||
let plan = plan_with(None, "/dav/file/u/photo.jpg");
|
||||
let logger = Logger::from_flags(false, 0);
|
||||
assert_eq!(display_or_basename(&plan, logger), "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalise_dav_date_parses_imf_fixdate() {
|
||||
let n = normalise_dav_date(&Some("Wed, 27 May 2026 07:59:07 GMT".to_owned()))
|
||||
.expect("imf parse");
|
||||
assert!(n.starts_with("2026-05-27T07:59:07"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalise_dav_date_returns_none_on_garbage() {
|
||||
assert!(normalise_dav_date(&Some("not a date".to_owned())).is_none());
|
||||
assert!(normalise_dav_date(&None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visited_hashset_breaks_recursion_on_cyclic_listing() {
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
visited.insert("/files/u/".to_owned());
|
||||
assert!(visited.insert("/files/u/a/".to_owned()));
|
||||
assert!(visited.insert("/files/u/a/b/".to_owned()));
|
||||
assert!(
|
||||
!visited.insert("/files/u/".to_owned()),
|
||||
"cycle back to root not re-recursed"
|
||||
);
|
||||
assert!(
|
||||
!visited.insert("/files/u/a/".to_owned()),
|
||||
"cycle back to a/ not re-recursed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod attachments;
|
||||
pub mod calendar;
|
||||
pub mod contacts;
|
||||
pub mod coordinator;
|
||||
pub mod folders;
|
||||
pub mod items;
|
||||
pub mod messages;
|
||||
|
||||
pub use coordinator::{EwsAuth, EwsImportConfig, run};
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
|
||||
use crate::db::blobs;
|
||||
use crate::error::Error;
|
||||
use crate::exchange_ews::error::EwsError;
|
||||
use crate::exchange_ews::parse::parse_get_attachment_inline;
|
||||
use crate::exchange_ews::xml::get_attachment_body;
|
||||
|
||||
use super::items::ItemRunCtx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FetchedAttachment {
|
||||
pub attachment_id: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub media_type: String,
|
||||
}
|
||||
|
||||
pub fn fetch_contact_photo(
|
||||
conn: &rusqlite::Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
attachment_id: &str,
|
||||
) -> Result<Option<(i64, String)>, Error> {
|
||||
let Some(att) = fetch_attachment_bytes(ctx, attachment_id).map_err(Error::from)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let blob_id = intern_attachment(conn, &att.bytes)?;
|
||||
Ok(Some((blob_id, att.media_type)))
|
||||
}
|
||||
|
||||
pub fn fetch_attachment_bytes(
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
attachment_id: &str,
|
||||
) -> Result<Option<FetchedAttachment>, EwsError> {
|
||||
let mut out = fetch_attachments(ctx, &[attachment_id])?;
|
||||
Ok(out.pop())
|
||||
}
|
||||
|
||||
pub fn fetch_attachments(
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
attachment_ids: &[&str],
|
||||
) -> Result<Vec<FetchedAttachment>, EwsError> {
|
||||
if attachment_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let batch = ctx.attachment_batch.max(1);
|
||||
let mut out: Vec<FetchedAttachment> = Vec::with_capacity(attachment_ids.len());
|
||||
for chunk in attachment_ids.chunks(batch) {
|
||||
let body = get_attachment_body(chunk);
|
||||
let resp = ctx.client.call(ctx.url, "GetAttachment", &body)?;
|
||||
let inline = parse_get_attachment_inline(&resp.body)?;
|
||||
for att in inline {
|
||||
let cleaned = strip_ascii_whitespace(att.content_base64.as_bytes());
|
||||
if cleaned.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let bytes = STANDARD.decode(&cleaned).map_err(|e| {
|
||||
EwsError::Malformed(format!("attachment {}: base64: {e}", att.attachment_id))
|
||||
})?;
|
||||
let media_type = att
|
||||
.content_type
|
||||
.unwrap_or_else(|| "application/octet-stream".to_owned());
|
||||
out.push(FetchedAttachment {
|
||||
attachment_id: att.attachment_id,
|
||||
bytes,
|
||||
media_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn intern_attachment(conn: &rusqlite::Connection, bytes: &[u8]) -> Result<i64, Error> {
|
||||
blobs::intern_blob(conn, bytes).map_err(|e| Error::Partial(e.to_string()))
|
||||
}
|
||||
|
||||
fn strip_ascii_whitespace(input: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(input.len());
|
||||
for b in input {
|
||||
if !matches!(b, b' ' | b'\t' | b'\n' | b'\r') {
|
||||
out.push(*b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip_ascii_whitespace;
|
||||
|
||||
#[test]
|
||||
fn strips_whitespace_efficiently() {
|
||||
let input = b"AB CD\nEF\tGH\r\nIJ";
|
||||
let cleaned = strip_ascii_whitespace(input);
|
||||
assert_eq!(cleaned, b"ABCDEFGHIJ");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::db::exchange_ews_ids;
|
||||
use crate::error::Error;
|
||||
use crate::exchange_ews::calendar_map::to_jscalendar;
|
||||
use crate::exchange_ews::parse::{CalendarItemRaw, parse_calendar_item};
|
||||
use crate::exchange_ews::types::{CalendarItemType, ItemId};
|
||||
use crate::exchange_ews::xml::ItemShape;
|
||||
use crate::logging::LEVEL_PROGRESS;
|
||||
use crate::sync::TypeCounts;
|
||||
|
||||
use super::attachments::{fetch_attachments, intern_attachment};
|
||||
use super::folders::FolderPlan;
|
||||
use super::items::{
|
||||
EnumerationMode, ItemRunCtx, delete_vanished, enumerate_folder, get_items, plan_for,
|
||||
};
|
||||
|
||||
pub fn reconcile_all(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
plan: &FolderPlan,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
for folder in &plan.calendar {
|
||||
let folder_id = &folder.folder.folder_id;
|
||||
let local_folder_id = match exchange_ews_ids::local_for_item(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CALENDAR,
|
||||
&folder_id.id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
if let Err(e) = reconcile_one(conn, ctx, folder_id, local_folder_id, counts) {
|
||||
ctx.logger
|
||||
.warn(&format!("calendar folder {} failed: {}", folder_id.id, e));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconcile_one(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
folder: &crate::exchange_ews::types::FolderId,
|
||||
local_folder_id: i64,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let prior = exchange_ews_ids::get_sync_state(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CALENDAR,
|
||||
&folder.id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let mut outcome = enumerate_folder(ctx, folder, prior.as_deref()).map_err(Error::from)?;
|
||||
outcome.items.retain(|s| {
|
||||
matches!(
|
||||
s.element.to_ascii_lowercase().as_str(),
|
||||
"calendaritem" | "item"
|
||||
)
|
||||
});
|
||||
let local = exchange_ews_ids::items_in_folder(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CALENDAR_EVENT,
|
||||
&folder.id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let plan = plan_for(&outcome, &local);
|
||||
if ctx.logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"EWS calendar folder {}: new={} changed={} vanished={}",
|
||||
folder.id,
|
||||
plan.new.len(),
|
||||
plan.present_changed.len(),
|
||||
plan.vanished.len()
|
||||
);
|
||||
}
|
||||
let mut to_fetch: Vec<ItemId> = plan.new.clone();
|
||||
for (id, _) in &plan.present_changed {
|
||||
to_fetch.push(id.clone());
|
||||
}
|
||||
if !to_fetch.is_empty() {
|
||||
let outcome = get_items(ctx, ItemShape::CalendarItem, &to_fetch).map_err(Error::from)?;
|
||||
counts.failed += outcome.failed_items;
|
||||
for msg in outcome.messages {
|
||||
if !msg.success {
|
||||
if matches!(
|
||||
msg.response_code,
|
||||
crate::exchange_ews::types::ResponseCode::ItemNotFound
|
||||
) {
|
||||
counts.skipped += 1;
|
||||
} else {
|
||||
counts.failed += 1;
|
||||
ctx.logger
|
||||
.warn(&format!("GetItem (calendar) error: {}", msg.response_code));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let parsed = parse_calendar_item(&msg.inner_xml).map_err(Error::from)?;
|
||||
if parsed.id.id.is_empty() {
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
parsed.calendar_item_type,
|
||||
Some(CalendarItemType::Occurrence) | Some(CalendarItemType::Exception)
|
||||
) {
|
||||
counts.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let existing = plan
|
||||
.present_changed
|
||||
.iter()
|
||||
.find(|(id, _)| id.id == parsed.id.id)
|
||||
.map(|(_, local)| *local);
|
||||
apply_event(
|
||||
conn,
|
||||
ctx,
|
||||
&parsed,
|
||||
local_folder_id,
|
||||
&folder.id,
|
||||
existing,
|
||||
counts,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
delete_vanished(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CALENDAR_EVENT,
|
||||
"calendar_events",
|
||||
&plan.vanished,
|
||||
counts,
|
||||
)?;
|
||||
if let EnumerationMode::Delta { new_sync_state, .. } = &outcome.mode {
|
||||
exchange_ews_ids::set_sync_state(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CALENDAR,
|
||||
&folder.id,
|
||||
new_sync_state,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_event(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
raw: &CalendarItemRaw,
|
||||
local_folder_id: i64,
|
||||
folder_id: &str,
|
||||
existing_local_id: Option<i64>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let mut built = to_jscalendar(raw);
|
||||
attach_calendar_links(conn, ctx, raw, &mut built.data)?;
|
||||
let calendar_ids = serde_json::json!([local_folder_id]).to_string();
|
||||
let data = built.data.to_string();
|
||||
let is_draft = if built.is_draft { 1 } else { 0 };
|
||||
let use_default_alerts = if built.use_default_alerts { 1 } else { 0 };
|
||||
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
match existing_local_id {
|
||||
Some(id) => {
|
||||
tx.execute(
|
||||
"UPDATE calendar_events SET calendar_ids = ?1, is_draft = ?2, \
|
||||
use_default_alerts = ?3, data = ?4, data_type = 'Event' WHERE id = ?5",
|
||||
params![calendar_ids, is_draft, use_default_alerts, data, id],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
exchange_ews_ids::update_change_key(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CALENDAR_EVENT,
|
||||
&raw.id.id,
|
||||
&raw.id.change_key,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.fetched += 1;
|
||||
}
|
||||
None => {
|
||||
tx.execute(
|
||||
"INSERT INTO calendar_events (calendar_ids, is_draft, use_default_alerts, data, data_type) \
|
||||
VALUES (?1, ?2, ?3, ?4, 'Event')",
|
||||
params![calendar_ids, is_draft, use_default_alerts, data],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_ews_ids::insert(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CALENDAR_EVENT,
|
||||
folder_id,
|
||||
&raw.id.id,
|
||||
&raw.id.change_key,
|
||||
new_id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.created += 1;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn attach_calendar_links(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
raw: &CalendarItemRaw,
|
||||
data: &mut Value,
|
||||
) -> Result<(), Error> {
|
||||
let refs: Vec<_> = raw
|
||||
.attachments
|
||||
.iter()
|
||||
.filter(|a| !a.is_item_attachment && !a.attachment_id.is_empty())
|
||||
.collect();
|
||||
if refs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let ids: Vec<&str> = refs.iter().map(|a| a.attachment_id.as_str()).collect();
|
||||
let fetched = match fetch_attachments(ctx, &ids) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
ctx.logger.warn(&format!(
|
||||
"calendar attachments fetch failed: {e}; continuing without enclosures"
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let mut links = serde_json::Map::new();
|
||||
for (idx, att) in (1u32..).zip(fetched) {
|
||||
let blob_id = intern_attachment(conn, &att.bytes)?;
|
||||
let declared = refs
|
||||
.iter()
|
||||
.find(|r| r.attachment_id == att.attachment_id)
|
||||
.and_then(|r| r.content_type.as_deref());
|
||||
let name = refs
|
||||
.iter()
|
||||
.find(|r| r.attachment_id == att.attachment_id)
|
||||
.and_then(|r| r.name.clone());
|
||||
let key = idx.to_string();
|
||||
let mut entry = serde_json::Map::new();
|
||||
entry.insert("@type".to_owned(), Value::String("Link".to_owned()));
|
||||
entry.insert("@blob".to_owned(), Value::from(blob_id));
|
||||
entry.insert(
|
||||
"contentType".to_owned(),
|
||||
Value::String(content_type_or_from(declared, &att.media_type)),
|
||||
);
|
||||
if let Some(n) = name {
|
||||
entry.insert("title".to_owned(), Value::String(n));
|
||||
}
|
||||
entry.insert("rel".to_owned(), Value::String("enclosure".to_owned()));
|
||||
links.insert(key, Value::Object(entry));
|
||||
}
|
||||
if links.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(obj) = data.as_object_mut() {
|
||||
if let Some(existing) = obj.get_mut("links").and_then(Value::as_object_mut) {
|
||||
for (k, v) in links {
|
||||
existing.insert(k, v);
|
||||
}
|
||||
} else {
|
||||
obj.insert("links".to_owned(), Value::Object(links));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn content_type_or_from(declared: Option<&str>, fallback: &str) -> String {
|
||||
declared
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::db::exchange_ews_ids;
|
||||
use crate::error::Error;
|
||||
use crate::exchange_ews::contact_map::{synthetic_uid, to_jscontact};
|
||||
use crate::exchange_ews::parse::{ContactItemRaw, parse_contact_item};
|
||||
use crate::exchange_ews::types::ItemId;
|
||||
use crate::exchange_ews::xml::ItemShape;
|
||||
use crate::logging::LEVEL_PROGRESS;
|
||||
use crate::sync::TypeCounts;
|
||||
|
||||
use super::attachments::fetch_contact_photo;
|
||||
use super::folders::FolderPlan;
|
||||
use super::items::{
|
||||
EnumerationMode, ItemRunCtx, delete_vanished, enumerate_folder, get_items, plan_for,
|
||||
};
|
||||
|
||||
pub fn reconcile_all(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
plan: &FolderPlan,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
for folder in &plan.contacts {
|
||||
let folder_id = &folder.folder.folder_id;
|
||||
let local_folder_id = match exchange_ews_ids::local_for_item(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::ADDRESS_BOOK,
|
||||
&folder_id.id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
if let Err(e) = reconcile_one(conn, ctx, folder_id, local_folder_id, counts) {
|
||||
ctx.logger
|
||||
.warn(&format!("contact folder {} failed: {}", folder_id.id, e));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconcile_one(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
folder: &crate::exchange_ews::types::FolderId,
|
||||
local_folder_id: i64,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let prior = exchange_ews_ids::get_sync_state(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::ADDRESS_BOOK,
|
||||
&folder.id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let outcome = enumerate_folder(ctx, folder, prior.as_deref()).map_err(Error::from)?;
|
||||
let local = exchange_ews_ids::items_in_folder(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CONTACT_CARD,
|
||||
&folder.id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let plan = plan_for(&outcome, &local);
|
||||
if ctx.logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"EWS contacts folder {}: new={} changed={} vanished={}",
|
||||
folder.id,
|
||||
plan.new.len(),
|
||||
plan.present_changed.len(),
|
||||
plan.vanished.len()
|
||||
);
|
||||
}
|
||||
let mut to_fetch: Vec<ItemId> = plan.new.clone();
|
||||
for (id, _) in &plan.present_changed {
|
||||
to_fetch.push(id.clone());
|
||||
}
|
||||
if !to_fetch.is_empty() {
|
||||
let outcome = get_items(ctx, ItemShape::Contact, &to_fetch).map_err(Error::from)?;
|
||||
counts.failed += outcome.failed_items;
|
||||
for msg in outcome.messages {
|
||||
if !msg.success {
|
||||
if matches!(
|
||||
msg.response_code,
|
||||
crate::exchange_ews::types::ResponseCode::ItemNotFound
|
||||
) {
|
||||
counts.skipped += 1;
|
||||
} else {
|
||||
counts.failed += 1;
|
||||
ctx.logger
|
||||
.warn(&format!("GetItem (contact) error: {}", msg.response_code));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let parsed = parse_contact_item(&msg.inner_xml).map_err(Error::from)?;
|
||||
if parsed.id.id.is_empty() {
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
let existing = plan
|
||||
.present_changed
|
||||
.iter()
|
||||
.find(|(id, _)| id.id == parsed.id.id)
|
||||
.map(|(_, local)| *local);
|
||||
apply_contact(
|
||||
conn,
|
||||
ctx,
|
||||
&parsed,
|
||||
local_folder_id,
|
||||
&folder.id,
|
||||
existing,
|
||||
counts,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
delete_vanished(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CONTACT_CARD,
|
||||
"contact_cards",
|
||||
&plan.vanished,
|
||||
counts,
|
||||
)?;
|
||||
if let EnumerationMode::Delta { new_sync_state, .. } = &outcome.mode {
|
||||
exchange_ews_ids::set_sync_state(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::ADDRESS_BOOK,
|
||||
&folder.id,
|
||||
new_sync_state,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_contact(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
item: &ContactItemRaw,
|
||||
local_folder_id: i64,
|
||||
folder_id: &str,
|
||||
existing_local_id: Option<i64>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let mut card = to_jscontact(item);
|
||||
if let Some(photo_att) = item.attachments.iter().find(|a| a.is_contact_photo) {
|
||||
match fetch_contact_photo(conn, ctx, &photo_att.attachment_id) {
|
||||
Ok(Some((blob_id, media_type))) => {
|
||||
let mut media = serde_json::Map::new();
|
||||
media.insert(
|
||||
"photo".to_owned(),
|
||||
serde_json::json!({
|
||||
"@type": "Media",
|
||||
"kind": "photo",
|
||||
"@blob": blob_id,
|
||||
"mediaType": media_type,
|
||||
}),
|
||||
);
|
||||
if let Some(m) = card.as_object_mut() {
|
||||
m.insert("media".to_owned(), serde_json::Value::Object(media));
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
ctx.logger
|
||||
.warn(&format!("contact photo fetch failed: {e}; continuing"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let uid = synthetic_uid(&item.id.id);
|
||||
let address_book_ids = serde_json::json!([local_folder_id]).to_string();
|
||||
let data = card.to_string();
|
||||
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
match existing_local_id {
|
||||
Some(id) => {
|
||||
tx.execute(
|
||||
"UPDATE contact_cards SET uid = ?1, address_book_ids = ?2, data = ?3 WHERE id = ?4",
|
||||
params![uid, address_book_ids, data, id],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
exchange_ews_ids::update_change_key(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CONTACT_CARD,
|
||||
&item.id.id,
|
||||
&item.id.change_key,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.fetched += 1;
|
||||
}
|
||||
None => {
|
||||
tx.execute(
|
||||
"INSERT INTO contact_cards (uid, address_book_ids, data) VALUES (?1, ?2, ?3)",
|
||||
params![uid, address_book_ids, data],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_ews_ids::insert(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CONTACT_CARD,
|
||||
folder_id,
|
||||
&item.id.id,
|
||||
&item.id.change_key,
|
||||
new_id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.created += 1;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db;
|
||||
use crate::db::sources::SourceKey;
|
||||
use crate::error::Error;
|
||||
use crate::exchange_ews::EwsClient;
|
||||
use crate::exchange_ews::autodiscover::{DiscoveryResult, discover};
|
||||
use crate::exchange_ews::oauth::{AcquiredToken, OAuthFlow, acquire};
|
||||
use crate::exchange_ews::types::MailboxKind;
|
||||
use crate::jmap::http::{Auth, RetryPolicy};
|
||||
use crate::logging::{LEVEL_DEFAULT, LEVEL_PROGRESS};
|
||||
use crate::sync::{CommonConfig, Summary, TypeCounts};
|
||||
|
||||
use super::folders::{self, plan_folders};
|
||||
use super::{calendar, contacts, messages};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EwsAuth {
|
||||
Basic { user: String, password: String },
|
||||
Bearer { token: String },
|
||||
OAuth(OAuthFlow),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EwsImportConfig {
|
||||
pub url: Option<String>,
|
||||
pub mailbox: Option<String>,
|
||||
pub mailbox_kind: MailboxKind,
|
||||
pub auth: EwsAuth,
|
||||
pub ews_connections: usize,
|
||||
pub getitem_batch: usize,
|
||||
pub attachment_batch: usize,
|
||||
pub use_syncfolderitems: bool,
|
||||
pub allow_source_change: bool,
|
||||
}
|
||||
|
||||
pub fn run(common: CommonConfig, config: EwsImportConfig) -> Result<Summary, Error> {
|
||||
let logger = common.logger;
|
||||
let mut conn = db::init::open(&common.archive)?;
|
||||
|
||||
let (auth, acquired) = resolve_auth(&config.auth, common.allow_invalid_certs)?;
|
||||
let discovery = run_autodiscover(&config, &acquired, common.allow_invalid_certs)?;
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"EWS discovery: url={} source={:?}",
|
||||
discovery.ews_url, discovery.source
|
||||
);
|
||||
}
|
||||
let mailbox = resolve_mailbox(&config, &acquired)?;
|
||||
let account_id = synthetic_account_id(&mailbox, config.mailbox_kind);
|
||||
let session_url = normalise_ews_url(&discovery.ews_url);
|
||||
enforce_basic_auth_policy(&config.auth, &session_url)?;
|
||||
let key = SourceKey {
|
||||
kind: "exchange_ews".to_owned(),
|
||||
session_url: session_url.clone(),
|
||||
account_id: account_id.clone(),
|
||||
};
|
||||
|
||||
if !common.dry_run
|
||||
&& let Some((url, acc)) =
|
||||
db::sources::conflicting_source(&conn, "exchange_ews", &session_url, &account_id)?
|
||||
&& !config.allow_source_change
|
||||
{
|
||||
return Err(Error::SourceChange(format!(
|
||||
"archive already records exchange_ews source ({url}, account {acc}); \
|
||||
pass --allow-source-change to import a different account"
|
||||
)));
|
||||
}
|
||||
|
||||
let client = EwsClient::new(
|
||||
auth,
|
||||
RetryPolicy::new(common.max_retries),
|
||||
common.allow_invalid_certs,
|
||||
);
|
||||
client.set_logger(logger);
|
||||
if matches!(config.mailbox_kind, MailboxKind::PublicFolders) {
|
||||
client.set_anchor_mailbox(None);
|
||||
} else {
|
||||
client.set_anchor_mailbox(Some(mailbox.clone()));
|
||||
}
|
||||
if let EwsAuth::OAuth(OAuthFlow::ClientCredentials { .. }) = &config.auth {
|
||||
client.set_impersonation(Some(mailbox.clone()));
|
||||
}
|
||||
spawn_token_refresher(
|
||||
&client,
|
||||
&config.auth,
|
||||
&acquired,
|
||||
common.allow_invalid_certs,
|
||||
logger,
|
||||
);
|
||||
|
||||
let username = match &config.auth {
|
||||
EwsAuth::Basic { user, .. } => user.clone(),
|
||||
EwsAuth::Bearer { .. } | EwsAuth::OAuth(_) => acquired
|
||||
.as_ref()
|
||||
.and_then(|a| a.upn.clone())
|
||||
.unwrap_or_else(|| mailbox.clone()),
|
||||
};
|
||||
|
||||
if common.dry_run {
|
||||
let summary = run_dry(&conn, &client, &session_url, &config, logger)?;
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
let account_name = acquired.as_ref().and_then(|a| a.name.clone());
|
||||
let source_id = db::sources::upsert_source(&conn, &key, account_name.as_deref(), &username)?;
|
||||
|
||||
let mut summary = Summary::default();
|
||||
let mut mailbox_counts = TypeCounts::default();
|
||||
let mut calendar_counts = TypeCounts::default();
|
||||
let mut addressbook_counts = TypeCounts::default();
|
||||
let mut email_counts = TypeCounts::default();
|
||||
let mut calendar_event_counts = TypeCounts::default();
|
||||
let mut contact_counts = TypeCounts::default();
|
||||
|
||||
let plan =
|
||||
plan_folders(&client, &session_url, config.mailbox_kind, logger).map_err(Error::from)?;
|
||||
|
||||
folders::reconcile(
|
||||
&mut conn,
|
||||
source_id,
|
||||
&plan,
|
||||
&mut mailbox_counts,
|
||||
&mut calendar_counts,
|
||||
&mut addressbook_counts,
|
||||
logger,
|
||||
)?;
|
||||
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!(
|
||||
"import: mailbox={} calendar={} addressbook={}",
|
||||
mailbox_counts.created + mailbox_counts.fetched,
|
||||
calendar_counts.created + calendar_counts.fetched,
|
||||
addressbook_counts.created + addressbook_counts.fetched,
|
||||
);
|
||||
}
|
||||
|
||||
let item_ctx = super::items::ItemRunCtx {
|
||||
client: &client,
|
||||
url: &session_url,
|
||||
source_id,
|
||||
batch_size: config.getitem_batch.max(1),
|
||||
attachment_batch: config.attachment_batch.max(1),
|
||||
connections: config.ews_connections.clamp(1, 8),
|
||||
use_syncfolderitems: config.use_syncfolderitems,
|
||||
logger,
|
||||
};
|
||||
|
||||
messages::reconcile_all(&mut conn, &item_ctx, &plan, &mut email_counts)?;
|
||||
contacts::reconcile_all(&mut conn, &item_ctx, &plan, &mut contact_counts)?;
|
||||
calendar::reconcile_all(&mut conn, &item_ctx, &plan, &mut calendar_event_counts)?;
|
||||
|
||||
summary.per_type.push(("mailbox", mailbox_counts));
|
||||
summary.per_type.push(("calendar", calendar_counts));
|
||||
summary.per_type.push(("addressbook", addressbook_counts));
|
||||
summary.per_type.push(("email", email_counts));
|
||||
summary
|
||||
.per_type
|
||||
.push(("calendarevent", calendar_event_counts));
|
||||
summary.per_type.push(("contactcard", contact_counts));
|
||||
|
||||
if !summary.any_failed()
|
||||
&& let Err(e) = run_gc(&conn)
|
||||
{
|
||||
logger.warn(&format!("blob GC skipped: {e}"));
|
||||
}
|
||||
|
||||
summary.retries_observed = client.retries_observed();
|
||||
summary.retry_after_sleeps = client.retry_after_sleeps();
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn run_dry(
|
||||
_conn: &Connection,
|
||||
client: &EwsClient,
|
||||
session_url: &str,
|
||||
config: &EwsImportConfig,
|
||||
logger: crate::logging::Logger,
|
||||
) -> Result<Summary, Error> {
|
||||
let plan =
|
||||
plan_folders(client, session_url, config.mailbox_kind, logger).map_err(Error::from)?;
|
||||
let mut summary = Summary::default();
|
||||
let mut mailbox_counts = TypeCounts::default();
|
||||
let mut calendar_counts = TypeCounts::default();
|
||||
let mut addressbook_counts = TypeCounts::default();
|
||||
mailbox_counts.created = plan.mail.len() as u64;
|
||||
calendar_counts.created = plan.calendar.len() as u64;
|
||||
addressbook_counts.created = plan.contacts.len() as u64;
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!(
|
||||
"dry-run: mailbox={} calendar={} addressbook={}",
|
||||
mailbox_counts.created, calendar_counts.created, addressbook_counts.created
|
||||
);
|
||||
}
|
||||
summary.per_type.push(("mailbox", mailbox_counts));
|
||||
summary.per_type.push(("calendar", calendar_counts));
|
||||
summary.per_type.push(("addressbook", addressbook_counts));
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn resolve_auth(
|
||||
auth: &EwsAuth,
|
||||
allow_invalid_certs: bool,
|
||||
) -> Result<(Auth, Option<AcquiredToken>), Error> {
|
||||
match auth {
|
||||
EwsAuth::Basic { user, password } => Ok((
|
||||
Auth::Basic {
|
||||
user: user.clone(),
|
||||
password: password.clone(),
|
||||
},
|
||||
None,
|
||||
)),
|
||||
EwsAuth::Bearer { token } => {
|
||||
let acq = acquire(
|
||||
&OAuthFlow::PreAcquired {
|
||||
token: token.clone(),
|
||||
},
|
||||
allow_invalid_certs,
|
||||
)
|
||||
.map_err(Error::from)?;
|
||||
Ok((
|
||||
Auth::Bearer {
|
||||
token: acq.access_token.clone(),
|
||||
},
|
||||
Some(acq),
|
||||
))
|
||||
}
|
||||
EwsAuth::OAuth(flow) => {
|
||||
let acq = acquire(flow, allow_invalid_certs).map_err(Error::from)?;
|
||||
Ok((
|
||||
Auth::Bearer {
|
||||
token: acq.access_token.clone(),
|
||||
},
|
||||
Some(acq),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_autodiscover(
|
||||
config: &EwsImportConfig,
|
||||
acquired: &Option<AcquiredToken>,
|
||||
allow_invalid_certs: bool,
|
||||
) -> Result<DiscoveryResult, Error> {
|
||||
let email = config
|
||||
.mailbox
|
||||
.clone()
|
||||
.or_else(|| acquired.as_ref().and_then(|a| a.upn.clone()));
|
||||
let result = discover(
|
||||
config.url.as_deref(),
|
||||
email.as_deref(),
|
||||
None,
|
||||
allow_invalid_certs,
|
||||
)
|
||||
.map_err(Error::from)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn resolve_mailbox(
|
||||
config: &EwsImportConfig,
|
||||
acquired: &Option<AcquiredToken>,
|
||||
) -> Result<String, Error> {
|
||||
if let Some(mb) = config.mailbox.as_ref() {
|
||||
return Ok(mb.clone());
|
||||
}
|
||||
if let Some(acq) = acquired.as_ref()
|
||||
&& let Some(upn) = acq.upn.as_ref()
|
||||
{
|
||||
return Ok(upn.clone());
|
||||
}
|
||||
if let EwsAuth::Basic { user, .. } = &config.auth
|
||||
&& user.contains('@')
|
||||
{
|
||||
return Ok(user.clone());
|
||||
}
|
||||
Err(Error::Account(
|
||||
"could not resolve mailbox SMTP; pass --mailbox".to_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn synthetic_account_id(mailbox: &str, kind: MailboxKind) -> String {
|
||||
match kind {
|
||||
MailboxKind::Primary => mailbox.to_owned(),
|
||||
MailboxKind::Archive => format!("{mailbox}#archive"),
|
||||
MailboxKind::PublicFolders => {
|
||||
let domain = mailbox.split('@').nth(1).unwrap_or("");
|
||||
format!("__public_folders__@{domain}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalise_ews_url(raw: &str) -> String {
|
||||
let Ok(parsed) = url::Url::parse(raw) else {
|
||||
return raw.to_owned();
|
||||
};
|
||||
let scheme = parsed.scheme().to_ascii_lowercase();
|
||||
let host = parsed.host_str().unwrap_or("").to_ascii_lowercase();
|
||||
let default_port = match scheme.as_str() {
|
||||
"https" => 443,
|
||||
"http" => 80,
|
||||
_ => 0,
|
||||
};
|
||||
let port = parsed.port().filter(|p| *p != default_port);
|
||||
let path = if parsed.path().is_empty() || parsed.path() == "/" {
|
||||
"/EWS/Exchange.asmx".to_owned()
|
||||
} else {
|
||||
let last = parsed.path().rsplit('/').next().unwrap_or("");
|
||||
if last.eq_ignore_ascii_case("Exchange.asmx") {
|
||||
let mut p = String::new();
|
||||
let mut parts: Vec<&str> = parsed.path().split('/').collect();
|
||||
if let Some(last_part) = parts.last_mut() {
|
||||
*last_part = "Exchange.asmx";
|
||||
}
|
||||
if let Some(second_last_idx) = parts.len().checked_sub(2)
|
||||
&& parts[second_last_idx].eq_ignore_ascii_case("EWS")
|
||||
{
|
||||
parts[second_last_idx] = "EWS";
|
||||
}
|
||||
p.push_str(&parts.join("/"));
|
||||
p
|
||||
} else {
|
||||
parsed.path().to_owned()
|
||||
}
|
||||
};
|
||||
match port {
|
||||
Some(p) => format!("{scheme}://{host}:{p}{path}"),
|
||||
None => format!("{scheme}://{host}{path}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_basic_auth_policy(auth: &EwsAuth, url: &str) -> Result<(), Error> {
|
||||
if let EwsAuth::Basic { .. } = auth {
|
||||
let parsed = url::Url::parse(url)
|
||||
.map_err(|e| Error::Usage(format!("invalid EWS URL {url:?}: {e}")))?;
|
||||
if parsed.scheme() != "https" {
|
||||
return Err(Error::Connection(
|
||||
"EWS requires https:// (basic auth refused on cleartext)".to_owned(),
|
||||
));
|
||||
}
|
||||
let host = parsed.host_str().unwrap_or("").to_ascii_lowercase();
|
||||
let blocked = [
|
||||
"outlook.office365.com",
|
||||
"outlook.office.com",
|
||||
"outlook.office365.us",
|
||||
"office365.us",
|
||||
];
|
||||
if blocked.iter().any(|b| host == *b || host.ends_with(b)) {
|
||||
return Err(Error::Connection(
|
||||
"Basic auth is disabled on Exchange Online; use --auth-bearer".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_token_refresher(
|
||||
client: &EwsClient,
|
||||
auth: &EwsAuth,
|
||||
initial: &Option<AcquiredToken>,
|
||||
allow_invalid_certs: bool,
|
||||
logger: crate::logging::Logger,
|
||||
) {
|
||||
let flow = match auth {
|
||||
EwsAuth::OAuth(flow) => flow.clone(),
|
||||
_ => return,
|
||||
};
|
||||
let exp = initial.as_ref().and_then(|t| {
|
||||
crate::exchange_ews::oauth::decode_jwt_claims(&t.access_token).and_then(|c| c.exp)
|
||||
});
|
||||
let Some(exp_secs) = exp else {
|
||||
return;
|
||||
};
|
||||
let client = client.clone();
|
||||
let initial_refresh = initial.as_ref().and_then(|t| t.refresh_token.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("vandelay-ews-token-refresh".to_owned())
|
||||
.spawn(move || {
|
||||
let mut next_exp = exp_secs;
|
||||
let mut refresh_token = initial_refresh;
|
||||
loop {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let refresh_at = next_exp.saturating_sub(5 * 60);
|
||||
if refresh_at > now {
|
||||
std::thread::sleep(std::time::Duration::from_secs(refresh_at - now));
|
||||
}
|
||||
let result = if let (Some(rt), OAuthFlow::DeviceCode { tenant, client_id }) =
|
||||
(refresh_token.as_deref(), &flow)
|
||||
{
|
||||
crate::exchange_ews::oauth::refresh_with_token(
|
||||
tenant,
|
||||
client_id,
|
||||
rt,
|
||||
allow_invalid_certs,
|
||||
)
|
||||
} else {
|
||||
crate::exchange_ews::oauth::acquire(&flow, allow_invalid_certs)
|
||||
};
|
||||
match result {
|
||||
Ok(tok) => {
|
||||
client.set_auth(crate::jmap::http::Auth::Bearer {
|
||||
token: tok.access_token.clone(),
|
||||
});
|
||||
if tok.refresh_token.is_some() {
|
||||
refresh_token = tok.refresh_token.clone();
|
||||
}
|
||||
if let Some(new_exp) =
|
||||
crate::exchange_ews::oauth::decode_jwt_claims(&tok.access_token)
|
||||
.and_then(|c| c.exp)
|
||||
{
|
||||
next_exp = new_exp;
|
||||
} else {
|
||||
next_exp = next_exp.saturating_add(50 * 60);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"EWS token refresh failed: {e}; sleeping 60s before retry"
|
||||
));
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn run_gc(conn: &Connection) -> Result<(), Error> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
db::blobs::gc_orphan_blobs(&tx)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn synthetic_account_id_uses_smtp_for_primary() {
|
||||
assert_eq!(
|
||||
synthetic_account_id("[email protected]", MailboxKind::Primary),
|
||||
"[email protected]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_account_id_archive_suffix() {
|
||||
assert_eq!(
|
||||
synthetic_account_id("[email protected]", MailboxKind::Archive),
|
||||
"[email protected]#archive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_account_id_public_folders_uses_tenant() {
|
||||
assert_eq!(
|
||||
synthetic_account_id("[email protected]", MailboxKind::PublicFolders),
|
||||
"[email protected]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_against_office365_endpoint_is_refused() {
|
||||
let auth = EwsAuth::Basic {
|
||||
user: "alice@x".to_owned(),
|
||||
password: "p".to_owned(),
|
||||
};
|
||||
let res =
|
||||
enforce_basic_auth_policy(&auth, "https://outlook.office365.com/EWS/Exchange.asmx");
|
||||
assert!(matches!(res, Err(Error::Connection(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_over_https_on_prem_is_allowed() {
|
||||
let auth = EwsAuth::Basic {
|
||||
user: "alice@x".to_owned(),
|
||||
password: "p".to_owned(),
|
||||
};
|
||||
assert!(
|
||||
enforce_basic_auth_policy(&auth, "https://exchange.example.com/EWS/Exchange.asmx")
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_over_http_is_refused() {
|
||||
let auth = EwsAuth::Basic {
|
||||
user: "alice".to_owned(),
|
||||
password: "p".to_owned(),
|
||||
};
|
||||
assert!(
|
||||
enforce_basic_auth_policy(&auth, "http://exchange.example.com/EWS/Exchange.asmx")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalise_ews_url_lowercases_host_and_keeps_canonical_path() {
|
||||
assert_eq!(
|
||||
normalise_ews_url("https://OUTLOOK.OFFICE365.COM/EWS/Exchange.asmx"),
|
||||
"https://outlook.office365.com/EWS/Exchange.asmx"
|
||||
);
|
||||
assert_eq!(
|
||||
normalise_ews_url("https://outlook.office365.com:443/ews/exchange.asmx"),
|
||||
"https://outlook.office365.com/EWS/Exchange.asmx"
|
||||
);
|
||||
assert_eq!(
|
||||
normalise_ews_url("https://srv.example.com:8443/EWS/Exchange.asmx"),
|
||||
"https://srv.example.com:8443/EWS/Exchange.asmx"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::db::exchange_ews_ids;
|
||||
use crate::error::Error;
|
||||
use crate::exchange_ews::EwsClient;
|
||||
use crate::exchange_ews::error::EwsError;
|
||||
use crate::exchange_ews::parse::{
|
||||
FolderEntry, parse_find_folder_response, parse_folder_inner, parse_get_folder_response,
|
||||
parse_response_messages,
|
||||
};
|
||||
use crate::exchange_ews::types::{DistinguishedFolderId, FolderClass, MailboxKind};
|
||||
use crate::exchange_ews::xml::{
|
||||
FolderRef, FolderShape, Traversal, find_folder_body, get_folder_body,
|
||||
};
|
||||
use crate::logging::{LEVEL_PROGRESS, Logger};
|
||||
use crate::sync::TypeCounts;
|
||||
|
||||
pub struct FolderPlan {
|
||||
pub mail: Vec<ClassifiedFolder>,
|
||||
pub calendar: Vec<ClassifiedFolder>,
|
||||
pub contacts: Vec<ClassifiedFolder>,
|
||||
pub well_known_roles: HashMap<String, &'static str>,
|
||||
pub root_folder_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClassifiedFolder {
|
||||
pub folder: FolderEntry,
|
||||
pub kind: FolderClass,
|
||||
}
|
||||
|
||||
pub fn plan_folders(
|
||||
client: &EwsClient,
|
||||
url: &str,
|
||||
mailbox_kind: MailboxKind,
|
||||
logger: Logger,
|
||||
) -> Result<FolderPlan, EwsError> {
|
||||
let root = mailbox_kind.distinguished_root();
|
||||
let request = find_folder_body(FolderRef::Distinguished(root), Traversal::Deep);
|
||||
let resp = client.call(url, "FindFolder", &request)?;
|
||||
let parsed = parse_find_folder_response(&resp.body)?;
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!("FindFolder returned {} folders", parsed.folders.len());
|
||||
}
|
||||
let mut mail = Vec::new();
|
||||
let mut calendar = Vec::new();
|
||||
let mut contacts = Vec::new();
|
||||
for folder in parsed.folders {
|
||||
let class = FolderClass::from_ipf(&folder.folder_class);
|
||||
match (mailbox_kind, class) {
|
||||
(MailboxKind::Archive, FolderClass::Mail) => {
|
||||
mail.push(ClassifiedFolder {
|
||||
folder,
|
||||
kind: class,
|
||||
});
|
||||
}
|
||||
(MailboxKind::Archive, _) => {}
|
||||
(_, FolderClass::Mail) => mail.push(ClassifiedFolder {
|
||||
folder,
|
||||
kind: class,
|
||||
}),
|
||||
(_, FolderClass::Calendar) => {
|
||||
calendar.push(ClassifiedFolder {
|
||||
folder,
|
||||
kind: class,
|
||||
});
|
||||
}
|
||||
(_, FolderClass::Contacts) => {
|
||||
contacts.push(ClassifiedFolder {
|
||||
folder,
|
||||
kind: class,
|
||||
});
|
||||
}
|
||||
(_, FolderClass::Skipped) => {}
|
||||
}
|
||||
}
|
||||
let well_known_roles = resolve_well_known_roles(client, url, mailbox_kind, logger)?;
|
||||
let root_folder_id = resolve_root_folder_id(client, url, mailbox_kind)?;
|
||||
Ok(FolderPlan {
|
||||
mail,
|
||||
calendar,
|
||||
contacts,
|
||||
well_known_roles,
|
||||
root_folder_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_well_known_roles(
|
||||
client: &EwsClient,
|
||||
url: &str,
|
||||
mailbox_kind: MailboxKind,
|
||||
logger: Logger,
|
||||
) -> Result<HashMap<String, &'static str>, EwsError> {
|
||||
if !matches!(mailbox_kind, MailboxKind::Primary) {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let folders: Vec<FolderRef<'_>> = WELL_KNOWN_ROLES
|
||||
.iter()
|
||||
.map(|(d, _)| FolderRef::Distinguished(*d))
|
||||
.collect();
|
||||
let request = get_folder_body(&folders, FolderShape::IdOnly);
|
||||
let resp = match client.call(url, "GetFolder", &request) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!("GetFolder for distinguished ids failed: {e}; roles will be empty");
|
||||
}
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
};
|
||||
let messages =
|
||||
parse_response_messages(&resp.body, b"GetFolderResponseMessage").unwrap_or_default();
|
||||
let mut map: HashMap<String, &'static str> = HashMap::new();
|
||||
for (msg, (_, role)) in messages.into_iter().zip(WELL_KNOWN_ROLES.iter()) {
|
||||
if !msg.success {
|
||||
continue;
|
||||
}
|
||||
let Ok(Some(entry)) = parse_folder_inner(&msg.inner_xml) else {
|
||||
continue;
|
||||
};
|
||||
if entry.folder_id.id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = role {
|
||||
map.insert(entry.folder_id.id, r);
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn resolve_root_folder_id(
|
||||
client: &EwsClient,
|
||||
url: &str,
|
||||
mailbox_kind: MailboxKind,
|
||||
) -> Result<String, EwsError> {
|
||||
let root = mailbox_kind.distinguished_root();
|
||||
let body = get_folder_body(&[FolderRef::Distinguished(root)], FolderShape::IdOnly);
|
||||
let resp = client.call(url, "GetFolder", &body)?;
|
||||
let entries = parse_get_folder_response(&resp.body)?;
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|e| e.folder_id.id)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
const WELL_KNOWN_ROLES: &[(DistinguishedFolderId, Option<&'static str>)] = &[
|
||||
(DistinguishedFolderId::Inbox, Some("inbox")),
|
||||
(DistinguishedFolderId::SentItems, Some("sent")),
|
||||
(DistinguishedFolderId::Drafts, Some("drafts")),
|
||||
(DistinguishedFolderId::DeletedItems, Some("trash")),
|
||||
(DistinguishedFolderId::JunkEmail, Some("junk")),
|
||||
(DistinguishedFolderId::Archive, Some("archive")),
|
||||
(DistinguishedFolderId::Outbox, None),
|
||||
(DistinguishedFolderId::ConversationHistory, None),
|
||||
];
|
||||
|
||||
pub fn reconcile(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
plan: &FolderPlan,
|
||||
mailbox_counts: &mut TypeCounts,
|
||||
calendar_counts: &mut TypeCounts,
|
||||
addressbook_counts: &mut TypeCounts,
|
||||
_logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let local_mailbox: HashMap<String, exchange_ews_ids::FolderRow> =
|
||||
exchange_ews_ids::folders_of_type(conn, source_id, exchange_ews_ids::MAILBOX)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let local_calendar: HashMap<String, exchange_ews_ids::FolderRow> =
|
||||
exchange_ews_ids::folders_of_type(conn, source_id, exchange_ews_ids::CALENDAR)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let local_addressbook: HashMap<String, exchange_ews_ids::FolderRow> =
|
||||
exchange_ews_ids::folders_of_type(conn, source_id, exchange_ews_ids::ADDRESS_BOOK)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
|
||||
let mut id_to_local: HashMap<String, i64> = HashMap::new();
|
||||
for (id, row) in local_mailbox
|
||||
.iter()
|
||||
.chain(local_calendar.iter())
|
||||
.chain(local_addressbook.iter())
|
||||
{
|
||||
id_to_local.insert(id.clone(), row.local_id);
|
||||
}
|
||||
|
||||
let ordered_mail = order_by_parent(&plan.mail);
|
||||
let ordered_calendar = order_by_parent(&plan.calendar);
|
||||
let ordered_contacts = order_by_parent(&plan.contacts);
|
||||
|
||||
let mut server_ids_mail: Vec<String> = Vec::new();
|
||||
let mut server_ids_calendar: Vec<String> = Vec::new();
|
||||
let mut server_ids_addressbook: Vec<String> = Vec::new();
|
||||
|
||||
for folder in &ordered_mail {
|
||||
server_ids_mail.push(folder.folder.folder_id.id.clone());
|
||||
let role = plan
|
||||
.well_known_roles
|
||||
.get(&folder.folder.folder_id.id)
|
||||
.copied();
|
||||
let mut ctx = UpsertCtx {
|
||||
conn,
|
||||
source_id,
|
||||
id_to_local: &mut id_to_local,
|
||||
local: &local_mailbox,
|
||||
counts: mailbox_counts,
|
||||
root_folder_id: &plan.root_folder_id,
|
||||
};
|
||||
upsert_mailbox(&mut ctx, folder, role)?;
|
||||
}
|
||||
for folder in &ordered_calendar {
|
||||
server_ids_calendar.push(folder.folder.folder_id.id.clone());
|
||||
let mut ctx = UpsertCtx {
|
||||
conn,
|
||||
source_id,
|
||||
id_to_local: &mut id_to_local,
|
||||
local: &local_calendar,
|
||||
counts: calendar_counts,
|
||||
root_folder_id: &plan.root_folder_id,
|
||||
};
|
||||
upsert_calendar(&mut ctx, folder)?;
|
||||
}
|
||||
for folder in &ordered_contacts {
|
||||
server_ids_addressbook.push(folder.folder.folder_id.id.clone());
|
||||
let mut ctx = UpsertCtx {
|
||||
conn,
|
||||
source_id,
|
||||
id_to_local: &mut id_to_local,
|
||||
local: &local_addressbook,
|
||||
counts: addressbook_counts,
|
||||
root_folder_id: &plan.root_folder_id,
|
||||
};
|
||||
upsert_address_book(&mut ctx, folder)?;
|
||||
}
|
||||
|
||||
delete_vanished(
|
||||
conn,
|
||||
source_id,
|
||||
exchange_ews_ids::MAILBOX,
|
||||
"mailboxes",
|
||||
&local_mailbox,
|
||||
&server_ids_mail,
|
||||
mailbox_counts,
|
||||
)?;
|
||||
delete_vanished(
|
||||
conn,
|
||||
source_id,
|
||||
exchange_ews_ids::CALENDAR,
|
||||
"calendars",
|
||||
&local_calendar,
|
||||
&server_ids_calendar,
|
||||
calendar_counts,
|
||||
)?;
|
||||
delete_vanished(
|
||||
conn,
|
||||
source_id,
|
||||
exchange_ews_ids::ADDRESS_BOOK,
|
||||
"address_books",
|
||||
&local_addressbook,
|
||||
&server_ids_addressbook,
|
||||
addressbook_counts,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn order_by_parent(folders: &[ClassifiedFolder]) -> Vec<&ClassifiedFolder> {
|
||||
let by_id: HashMap<&str, &ClassifiedFolder> = folders
|
||||
.iter()
|
||||
.map(|f| (f.folder.folder_id.id.as_str(), f))
|
||||
.collect();
|
||||
let mut depths: HashMap<&str, usize> = HashMap::new();
|
||||
fn depth_of<'a>(
|
||||
id: &'a str,
|
||||
by_id: &HashMap<&'a str, &'a ClassifiedFolder>,
|
||||
memo: &mut HashMap<&'a str, usize>,
|
||||
seen: &mut std::collections::HashSet<&'a str>,
|
||||
) -> usize {
|
||||
if let Some(d) = memo.get(id) {
|
||||
return *d;
|
||||
}
|
||||
if !seen.insert(id) {
|
||||
return 0;
|
||||
}
|
||||
let d = match by_id.get(id).and_then(|f| f.folder.parent_id.as_deref()) {
|
||||
Some(parent) if by_id.contains_key(parent) => 1 + depth_of(parent, by_id, memo, seen),
|
||||
_ => 0,
|
||||
};
|
||||
memo.insert(id, d);
|
||||
d
|
||||
}
|
||||
for f in folders {
|
||||
let id = f.folder.folder_id.id.as_str();
|
||||
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
let d = depth_of(id, &by_id, &mut depths, &mut seen);
|
||||
depths.insert(id, d);
|
||||
}
|
||||
let mut out: Vec<&ClassifiedFolder> = folders.iter().collect();
|
||||
out.sort_by_key(|f| {
|
||||
depths
|
||||
.get(f.folder.folder_id.id.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
struct UpsertCtx<'a> {
|
||||
conn: &'a mut Connection,
|
||||
source_id: i64,
|
||||
id_to_local: &'a mut HashMap<String, i64>,
|
||||
local: &'a HashMap<String, exchange_ews_ids::FolderRow>,
|
||||
counts: &'a mut TypeCounts,
|
||||
root_folder_id: &'a str,
|
||||
}
|
||||
|
||||
fn upsert_mailbox(
|
||||
ctx: &mut UpsertCtx<'_>,
|
||||
folder: &ClassifiedFolder,
|
||||
role: Option<&'static str>,
|
||||
) -> Result<(), Error> {
|
||||
let parent_local = parent_local_id(folder, ctx.id_to_local, ctx.root_folder_id);
|
||||
let tx = ctx
|
||||
.conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let existing = ctx.local.get(&folder.folder.folder_id.id);
|
||||
let local_id = if let Some(row) = existing {
|
||||
tx.execute(
|
||||
"UPDATE mailboxes SET name = ?1, parent_id = ?2, role = ?3 WHERE id = ?4",
|
||||
params![folder.folder.display_name, parent_local, role, row.local_id],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
exchange_ews_ids::update_change_key(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::MAILBOX,
|
||||
&folder.folder.folder_id.id,
|
||||
&folder.folder.folder_id.change_key,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
ctx.counts.fetched += 1;
|
||||
row.local_id
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed) \
|
||||
VALUES (?1, ?2, ?3, 0, 1)",
|
||||
params![folder.folder.display_name, parent_local, role],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_ews_ids::insert(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::MAILBOX,
|
||||
folder.folder.parent_id.as_deref().unwrap_or(""),
|
||||
&folder.folder.folder_id.id,
|
||||
&folder.folder.folder_id.change_key,
|
||||
new_id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
ctx.counts.created += 1;
|
||||
new_id
|
||||
};
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
ctx.id_to_local
|
||||
.insert(folder.folder.folder_id.id.clone(), local_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_calendar(ctx: &mut UpsertCtx<'_>, folder: &ClassifiedFolder) -> Result<(), Error> {
|
||||
let tx = ctx
|
||||
.conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let existing = ctx.local.get(&folder.folder.folder_id.id);
|
||||
let local_id = if let Some(row) = existing {
|
||||
tx.execute(
|
||||
"UPDATE calendars SET name = ?1 WHERE id = ?2",
|
||||
params![folder.folder.display_name, row.local_id],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
exchange_ews_ids::update_change_key(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CALENDAR,
|
||||
&folder.folder.folder_id.id,
|
||||
&folder.folder.folder_id.change_key,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
ctx.counts.fetched += 1;
|
||||
row.local_id
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO calendars (name, sort_order, is_subscribed, is_visible) \
|
||||
VALUES (?1, 0, 1, 1)",
|
||||
params![folder.folder.display_name],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_ews_ids::insert(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::CALENDAR,
|
||||
folder.folder.parent_id.as_deref().unwrap_or(""),
|
||||
&folder.folder.folder_id.id,
|
||||
&folder.folder.folder_id.change_key,
|
||||
new_id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
ctx.counts.created += 1;
|
||||
new_id
|
||||
};
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
ctx.id_to_local
|
||||
.insert(folder.folder.folder_id.id.clone(), local_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_address_book(ctx: &mut UpsertCtx<'_>, folder: &ClassifiedFolder) -> Result<(), Error> {
|
||||
let tx = ctx
|
||||
.conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let existing = ctx.local.get(&folder.folder.folder_id.id);
|
||||
let local_id = if let Some(row) = existing {
|
||||
tx.execute(
|
||||
"UPDATE address_books SET name = ?1 WHERE id = ?2",
|
||||
params![folder.folder.display_name, row.local_id],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
exchange_ews_ids::update_change_key(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::ADDRESS_BOOK,
|
||||
&folder.folder.folder_id.id,
|
||||
&folder.folder.folder_id.change_key,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
ctx.counts.fetched += 1;
|
||||
row.local_id
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO address_books (name, sort_order, is_subscribed) \
|
||||
VALUES (?1, 0, 1)",
|
||||
params![folder.folder.display_name],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_ews_ids::insert(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::ADDRESS_BOOK,
|
||||
folder.folder.parent_id.as_deref().unwrap_or(""),
|
||||
&folder.folder.folder_id.id,
|
||||
&folder.folder.folder_id.change_key,
|
||||
new_id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
ctx.counts.created += 1;
|
||||
new_id
|
||||
};
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
ctx.id_to_local
|
||||
.insert(folder.folder.folder_id.id.clone(), local_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parent_local_id(
|
||||
folder: &ClassifiedFolder,
|
||||
id_to_local: &HashMap<String, i64>,
|
||||
root_folder_id: &str,
|
||||
) -> Option<i64> {
|
||||
let parent_id = folder.folder.parent_id.as_deref()?;
|
||||
if parent_id == root_folder_id || parent_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
id_to_local.get(parent_id).copied()
|
||||
}
|
||||
|
||||
fn delete_vanished(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
type_name: &str,
|
||||
table: &str,
|
||||
local: &HashMap<String, exchange_ews_ids::FolderRow>,
|
||||
server_ids: &[String],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let server_set: std::collections::HashSet<&str> =
|
||||
server_ids.iter().map(String::as_str).collect();
|
||||
let mut vanished: Vec<(&str, &exchange_ews_ids::FolderRow)> = local
|
||||
.iter()
|
||||
.filter(|(id, _)| !server_set.contains(id.as_str()))
|
||||
.map(|(id, row)| (id.as_str(), row))
|
||||
.collect();
|
||||
if type_name == exchange_ews_ids::MAILBOX {
|
||||
vanished.sort_by_key(|(_, row)| std::cmp::Reverse(folder_depth(row, local)));
|
||||
}
|
||||
for (item_id, row) in vanished {
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let result = tx.execute(
|
||||
&format!("DELETE FROM {table} WHERE id = ?1"),
|
||||
params![row.local_id],
|
||||
);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
exchange_ews_ids::delete_item(&tx, source_id, type_name, item_id)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.deleted += 1;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx.rollback();
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn folder_depth(
|
||||
row: &exchange_ews_ids::FolderRow,
|
||||
local: &HashMap<String, exchange_ews_ids::FolderRow>,
|
||||
) -> usize {
|
||||
let mut depth = 0;
|
||||
let mut cursor = row.folder_id.as_str();
|
||||
let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
while !cursor.is_empty() && visited.insert(cursor) {
|
||||
match local.get(cursor) {
|
||||
Some(parent) => {
|
||||
depth += 1;
|
||||
cursor = parent.folder_id.as_str();
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
depth
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn row(id: &str, parent_ews_id: &str, local_id: i64) -> exchange_ews_ids::FolderRow {
|
||||
exchange_ews_ids::FolderRow {
|
||||
item_id: id.to_owned(),
|
||||
folder_id: parent_ews_id.to_owned(),
|
||||
change_key: String::new(),
|
||||
local_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_depth_walks_parent_chain() {
|
||||
let mut local: HashMap<String, exchange_ews_ids::FolderRow> = HashMap::new();
|
||||
local.insert("ROOT".to_owned(), row("ROOT", "", 1));
|
||||
local.insert("CHILD".to_owned(), row("CHILD", "ROOT", 2));
|
||||
local.insert("GRAND".to_owned(), row("GRAND", "CHILD", 3));
|
||||
assert_eq!(folder_depth(local.get("ROOT").unwrap(), &local), 0);
|
||||
assert_eq!(folder_depth(local.get("CHILD").unwrap(), &local), 1);
|
||||
assert_eq!(folder_depth(local.get("GRAND").unwrap(), &local), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_depth_handles_unknown_parent() {
|
||||
let mut local: HashMap<String, exchange_ews_ids::FolderRow> = HashMap::new();
|
||||
local.insert("X".to_owned(), row("X", "ORPHAN", 1));
|
||||
assert_eq!(folder_depth(local.get("X").unwrap(), &local), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_depth_stops_on_cycle() {
|
||||
let mut local: HashMap<String, exchange_ews_ids::FolderRow> = HashMap::new();
|
||||
local.insert("A".to_owned(), row("A", "B", 1));
|
||||
local.insert("B".to_owned(), row("B", "A", 2));
|
||||
let d = folder_depth(local.get("A").unwrap(), &local);
|
||||
assert!(d <= 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::exchange_ews::EwsClient;
|
||||
use crate::exchange_ews::error::EwsError;
|
||||
use crate::exchange_ews::parse::{
|
||||
SyncChange, parse_find_item_response, parse_response_messages, parse_sync_folder_items_response,
|
||||
};
|
||||
use crate::exchange_ews::types::{FolderId, ItemId, ResponseCode};
|
||||
use crate::exchange_ews::xml::{
|
||||
FolderRef, ItemShape, Traversal, find_item_body, get_item_body, sync_folder_items_body,
|
||||
};
|
||||
use crate::logging::Logger;
|
||||
|
||||
pub struct ItemRunCtx<'a> {
|
||||
pub client: &'a EwsClient,
|
||||
pub url: &'a str,
|
||||
pub source_id: i64,
|
||||
pub batch_size: usize,
|
||||
pub attachment_batch: usize,
|
||||
pub connections: usize,
|
||||
pub use_syncfolderitems: bool,
|
||||
pub logger: Logger,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnumeratedItem {
|
||||
pub element: String,
|
||||
pub id: ItemId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnumerationOutcome {
|
||||
pub items: Vec<EnumeratedItem>,
|
||||
pub mode: EnumerationMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EnumerationMode {
|
||||
Full,
|
||||
Delta {
|
||||
deletions: Vec<String>,
|
||||
new_sync_state: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn enumerate_folder(
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
folder: &FolderId,
|
||||
prior_sync_state: Option<&str>,
|
||||
) -> Result<EnumerationOutcome, EwsError> {
|
||||
if ctx.use_syncfolderitems
|
||||
&& let Some(state) = prior_sync_state
|
||||
&& !state.is_empty()
|
||||
&& let Some(outcome) = try_sync_folder_items(ctx, folder, state)?
|
||||
{
|
||||
return Ok(outcome);
|
||||
}
|
||||
enumerate_via_find_item(ctx, folder).map(|items| EnumerationOutcome {
|
||||
items,
|
||||
mode: EnumerationMode::Full,
|
||||
})
|
||||
}
|
||||
|
||||
fn sync_fallback_codes(code: &ResponseCode) -> bool {
|
||||
matches!(
|
||||
code,
|
||||
ResponseCode::InvalidIdMalformed | ResponseCode::AccessDenied | ResponseCode::Other(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn try_sync_folder_items(
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
folder: &FolderId,
|
||||
prior_state: &str,
|
||||
) -> Result<Option<EnumerationOutcome>, EwsError> {
|
||||
let mut sync_state = prior_state.to_owned();
|
||||
let mut items: Vec<EnumeratedItem> = Vec::new();
|
||||
let mut deletions: Vec<String> = Vec::new();
|
||||
let mut iters = 0;
|
||||
loop {
|
||||
let body = sync_folder_items_body(folder, &sync_state, 512);
|
||||
let resp = match ctx.client.call(ctx.url, "SyncFolderItems", &body) {
|
||||
Ok(r) => r,
|
||||
Err(EwsError::SoapFault {
|
||||
code: ResponseCode::InvalidSyncStateData,
|
||||
..
|
||||
}) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(EwsError::SoapFault { code, .. }) if sync_fallback_codes(&code) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let parsed = parse_sync_folder_items_response(&resp.body)?;
|
||||
for change in parsed.changes {
|
||||
match change {
|
||||
SyncChange::Create { id, element } | SyncChange::Update { id, element } => {
|
||||
items.push(EnumeratedItem { element, id });
|
||||
}
|
||||
SyncChange::Delete { id } => {
|
||||
deletions.push(id.id);
|
||||
}
|
||||
SyncChange::ReadFlagChange { id, .. } => {
|
||||
items.push(EnumeratedItem {
|
||||
element: "Message".to_owned(),
|
||||
id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
sync_state = parsed.sync_state;
|
||||
if !parsed.more {
|
||||
break;
|
||||
}
|
||||
iters += 1;
|
||||
if iters > 200 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(EnumerationOutcome {
|
||||
items,
|
||||
mode: EnumerationMode::Delta {
|
||||
deletions,
|
||||
new_sync_state: sync_state,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn enumerate_via_find_item(
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
folder: &FolderId,
|
||||
) -> Result<Vec<EnumeratedItem>, EwsError> {
|
||||
let mut items: Vec<EnumeratedItem> = Vec::new();
|
||||
let mut offset: u32 = 0;
|
||||
let page_size: u32 = 500;
|
||||
loop {
|
||||
let body = find_item_body(
|
||||
FolderRef::Concrete(folder),
|
||||
Traversal::Shallow,
|
||||
offset,
|
||||
page_size,
|
||||
);
|
||||
let resp = ctx.client.call(ctx.url, "FindItem", &body)?;
|
||||
let parsed = parse_find_item_response(&resp.body)?;
|
||||
let returned = parsed.items.len() as u32;
|
||||
for entry in parsed.items {
|
||||
items.push(EnumeratedItem {
|
||||
element: entry.element,
|
||||
id: entry.id,
|
||||
});
|
||||
}
|
||||
if !parsed.more {
|
||||
break;
|
||||
}
|
||||
if returned == 0 {
|
||||
break;
|
||||
}
|
||||
offset = offset.saturating_add(page_size);
|
||||
if offset > 1_000_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffPlan {
|
||||
pub new: Vec<ItemId>,
|
||||
pub vanished: Vec<(String, i64)>,
|
||||
pub present_changed: Vec<(ItemId, i64)>,
|
||||
pub present_unchanged: Vec<(String, i64)>,
|
||||
}
|
||||
|
||||
pub fn plan_for(
|
||||
outcome: &EnumerationOutcome,
|
||||
local: &[crate::db::exchange_ews_ids::ItemRow],
|
||||
) -> DiffPlan {
|
||||
match &outcome.mode {
|
||||
EnumerationMode::Full => diff(&outcome.items, local),
|
||||
EnumerationMode::Delta { deletions, .. } => diff_delta(&outcome.items, deletions, local),
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_delta(
|
||||
server_changes: &[EnumeratedItem],
|
||||
deletions: &[String],
|
||||
local: &[crate::db::exchange_ews_ids::ItemRow],
|
||||
) -> DiffPlan {
|
||||
let local_map: HashMap<&str, &crate::db::exchange_ews_ids::ItemRow> =
|
||||
local.iter().map(|r| (r.item_id.as_str(), r)).collect();
|
||||
let mut plan = DiffPlan {
|
||||
new: Vec::new(),
|
||||
vanished: Vec::new(),
|
||||
present_changed: Vec::new(),
|
||||
present_unchanged: Vec::new(),
|
||||
};
|
||||
for s in server_changes {
|
||||
match local_map.get(s.id.id.as_str()) {
|
||||
None => plan.new.push(s.id.clone()),
|
||||
Some(row) => plan.present_changed.push((s.id.clone(), row.local_id)),
|
||||
}
|
||||
}
|
||||
for d in deletions {
|
||||
if let Some(row) = local_map.get(d.as_str()) {
|
||||
plan.vanished.push((d.clone(), row.local_id));
|
||||
}
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
pub fn diff(server: &[EnumeratedItem], local: &[crate::db::exchange_ews_ids::ItemRow]) -> DiffPlan {
|
||||
let local_map: HashMap<&str, &crate::db::exchange_ews_ids::ItemRow> =
|
||||
local.iter().map(|r| (r.item_id.as_str(), r)).collect();
|
||||
let server_map: HashMap<&str, &EnumeratedItem> =
|
||||
server.iter().map(|s| (s.id.id.as_str(), s)).collect();
|
||||
let mut plan = DiffPlan {
|
||||
new: Vec::new(),
|
||||
vanished: Vec::new(),
|
||||
present_changed: Vec::new(),
|
||||
present_unchanged: Vec::new(),
|
||||
};
|
||||
for s in server {
|
||||
match local_map.get(s.id.id.as_str()) {
|
||||
None => plan.new.push(s.id.clone()),
|
||||
Some(row) => {
|
||||
if s.id.change_key.is_empty() || s.id.change_key == row.change_key {
|
||||
plan.present_unchanged
|
||||
.push((row.item_id.clone(), row.local_id));
|
||||
} else {
|
||||
plan.present_changed.push((s.id.clone(), row.local_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in local {
|
||||
if !server_map.contains_key(row.item_id.as_str()) {
|
||||
plan.vanished.push((row.item_id.clone(), row.local_id));
|
||||
}
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
pub struct GetItemBatchOutcome {
|
||||
pub messages: Vec<crate::exchange_ews::parse::ResponseMessage>,
|
||||
pub failed_items: u64,
|
||||
}
|
||||
|
||||
fn is_per_batch_fault(err: &EwsError) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
EwsError::SoapFault { .. } | EwsError::HttpStatus { .. } | EwsError::Malformed(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_items(
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
shape: ItemShape,
|
||||
ids: &[ItemId],
|
||||
) -> Result<GetItemBatchOutcome, EwsError> {
|
||||
let batch = ctx.batch_size.max(1);
|
||||
let workers = ctx.connections.clamp(1, 8);
|
||||
let mut failed_items: u64 = 0;
|
||||
if workers <= 1 || ids.len() <= batch {
|
||||
let mut all = Vec::new();
|
||||
for chunk in ids.chunks(batch) {
|
||||
let body = get_item_body(shape, chunk);
|
||||
match ctx.client.call(ctx.url, "GetItem", &body) {
|
||||
Ok(resp) => match parse_response_messages(&resp.body, b"GetItemResponseMessage") {
|
||||
Ok(mut msgs) => all.append(&mut msgs),
|
||||
Err(e) if is_per_batch_fault(&e) => {
|
||||
ctx.logger.warn(&format!(
|
||||
"GetItem batch parse failed ({e}); {} ids left for retry next run",
|
||||
chunk.len()
|
||||
));
|
||||
failed_items += chunk.len() as u64;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
},
|
||||
Err(e) if is_per_batch_fault(&e) => {
|
||||
ctx.logger.warn(&format!(
|
||||
"GetItem batch failed ({e}); {} ids left for retry next run",
|
||||
chunk.len()
|
||||
));
|
||||
failed_items += chunk.len() as u64;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
return Ok(GetItemBatchOutcome {
|
||||
messages: all,
|
||||
failed_items,
|
||||
});
|
||||
}
|
||||
let client = ctx.client.clone();
|
||||
let url = ctx.url.to_owned();
|
||||
type BatchResult = (
|
||||
usize,
|
||||
Result<Vec<crate::exchange_ews::parse::ResponseMessage>, EwsError>,
|
||||
);
|
||||
let pool: crate::sync::import_jmap::pool::Pool<Vec<ItemId>, BatchResult> =
|
||||
crate::sync::import_jmap::pool::Pool::new(workers, move |chunk: Vec<ItemId>| {
|
||||
let body = get_item_body(shape, &chunk);
|
||||
let n = chunk.len();
|
||||
let result = match client.call(&url, "GetItem", &body) {
|
||||
Ok(resp) => parse_response_messages(&resp.body, b"GetItemResponseMessage"),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
(n, result)
|
||||
});
|
||||
let mut submitted = 0usize;
|
||||
for chunk in ids.chunks(batch) {
|
||||
pool.submit(chunk.to_vec());
|
||||
submitted += 1;
|
||||
}
|
||||
let mut all = Vec::new();
|
||||
let mut abort_err: Option<EwsError> = None;
|
||||
for _ in 0..submitted {
|
||||
match pool.results().recv() {
|
||||
Ok((_, Ok(mut msgs))) => all.append(&mut msgs),
|
||||
Ok((n, Err(e))) => {
|
||||
if is_per_batch_fault(&e) {
|
||||
ctx.logger.warn(&format!(
|
||||
"GetItem batch failed ({e}); {n} ids left for retry next run"
|
||||
));
|
||||
failed_items += n as u64;
|
||||
} else if abort_err.is_none() {
|
||||
abort_err = Some(e);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if let Some(e) = abort_err {
|
||||
return Err(e);
|
||||
}
|
||||
Ok(GetItemBatchOutcome {
|
||||
messages: all,
|
||||
failed_items,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_vanished(
|
||||
conn: &mut rusqlite::Connection,
|
||||
source_id: i64,
|
||||
type_name: &str,
|
||||
table: &str,
|
||||
vanished: &[(String, i64)],
|
||||
counts: &mut crate::sync::TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
use rusqlite::params;
|
||||
for (item_id, local_id) in vanished {
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
match tx.execute(
|
||||
&format!("DELETE FROM {table} WHERE id = ?1"),
|
||||
params![local_id],
|
||||
) {
|
||||
Ok(_) => {
|
||||
crate::db::exchange_ews_ids::delete_item(&tx, source_id, type_name, item_id)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.deleted += 1;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx.rollback();
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn diff_delta_only_yields_changes_from_server_response() {
|
||||
use crate::db::exchange_ews_ids::ItemRow;
|
||||
let outcome = EnumerationOutcome {
|
||||
items: vec![EnumeratedItem {
|
||||
element: "Message".to_owned(),
|
||||
id: ItemId::new("A", "ck-2"),
|
||||
}],
|
||||
mode: EnumerationMode::Delta {
|
||||
deletions: vec!["Z".to_owned()],
|
||||
new_sync_state: "STATE2".to_owned(),
|
||||
},
|
||||
};
|
||||
let local = vec![
|
||||
ItemRow {
|
||||
item_id: "A".to_owned(),
|
||||
change_key: "ck-1".to_owned(),
|
||||
local_id: 1,
|
||||
},
|
||||
ItemRow {
|
||||
item_id: "B".to_owned(),
|
||||
change_key: "ck-1".to_owned(),
|
||||
local_id: 2,
|
||||
},
|
||||
ItemRow {
|
||||
item_id: "Z".to_owned(),
|
||||
change_key: "ck-9".to_owned(),
|
||||
local_id: 99,
|
||||
},
|
||||
];
|
||||
let plan = plan_for(&outcome, &local);
|
||||
assert!(plan.new.is_empty());
|
||||
assert_eq!(plan.present_changed.len(), 1);
|
||||
assert_eq!(plan.present_changed[0].0.id, "A");
|
||||
assert_eq!(plan.vanished.len(), 1);
|
||||
assert_eq!(plan.vanished[0].0, "Z");
|
||||
assert_eq!(plan.present_unchanged.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_splits_into_new_unchanged_changed_vanished() {
|
||||
use crate::db::exchange_ews_ids::ItemRow;
|
||||
let server = vec![
|
||||
EnumeratedItem {
|
||||
element: "Message".to_owned(),
|
||||
id: ItemId::new("A", "ck-1"),
|
||||
},
|
||||
EnumeratedItem {
|
||||
element: "Message".to_owned(),
|
||||
id: ItemId::new("B", "ck-1"),
|
||||
},
|
||||
EnumeratedItem {
|
||||
element: "Message".to_owned(),
|
||||
id: ItemId::new("C", "ck-2"),
|
||||
},
|
||||
];
|
||||
let local = vec![
|
||||
ItemRow {
|
||||
item_id: "A".to_owned(),
|
||||
change_key: "ck-1".to_owned(),
|
||||
local_id: 1,
|
||||
},
|
||||
ItemRow {
|
||||
item_id: "B".to_owned(),
|
||||
change_key: "ck-0".to_owned(),
|
||||
local_id: 2,
|
||||
},
|
||||
ItemRow {
|
||||
item_id: "Z".to_owned(),
|
||||
change_key: "ck-9".to_owned(),
|
||||
local_id: 99,
|
||||
},
|
||||
];
|
||||
let plan = diff(&server, &local);
|
||||
assert_eq!(plan.new.len(), 1);
|
||||
assert_eq!(plan.new[0].id, "C");
|
||||
assert_eq!(plan.present_unchanged.len(), 1);
|
||||
assert_eq!(plan.present_unchanged[0].0, "A");
|
||||
assert_eq!(plan.present_changed.len(), 1);
|
||||
assert_eq!(plan.present_changed[0].0.id, "B");
|
||||
assert_eq!(plan.vanished.len(), 1);
|
||||
assert_eq!(plan.vanished[0].0, "Z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use rusqlite::{Connection, params};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::{blobs, exchange_ews_ids};
|
||||
use crate::error::Error;
|
||||
use crate::exchange_ews::parse::{MessageItem, parse_message_item};
|
||||
use crate::exchange_ews::types::ItemId;
|
||||
use crate::exchange_ews::xml::ItemShape;
|
||||
use crate::logging::LEVEL_PROGRESS;
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::emailmeta::email_meta_from_blob;
|
||||
use crate::sync::keys::index_to_json;
|
||||
|
||||
use super::folders::FolderPlan;
|
||||
use super::items::{
|
||||
EnumerationMode, ItemRunCtx, delete_vanished, enumerate_folder, get_items, plan_for,
|
||||
};
|
||||
|
||||
pub fn reconcile_all(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
plan: &FolderPlan,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
for folder in &plan.mail {
|
||||
let folder_id = &folder.folder.folder_id;
|
||||
let local_folder_id = match exchange_ews_ids::local_for_item(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::MAILBOX,
|
||||
&folder_id.id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
if let Err(e) = reconcile_one_folder(conn, ctx, folder_id, local_folder_id, counts) {
|
||||
ctx.logger
|
||||
.warn(&format!("email folder {} failed: {}", folder_id.id, e));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconcile_one_folder(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
folder: &crate::exchange_ews::types::FolderId,
|
||||
local_folder_id: i64,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let prior = exchange_ews_ids::get_sync_state(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::MAILBOX,
|
||||
&folder.id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let outcome = enumerate_folder(ctx, folder, prior.as_deref()).map_err(Error::from)?;
|
||||
let local =
|
||||
exchange_ews_ids::items_in_folder(conn, ctx.source_id, exchange_ews_ids::EMAIL, &folder.id)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let plan = plan_for(&outcome, &local);
|
||||
if ctx.logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"EWS folder {}: new={} changed={} vanished={} unchanged={}",
|
||||
folder.id,
|
||||
plan.new.len(),
|
||||
plan.present_changed.len(),
|
||||
plan.vanished.len(),
|
||||
plan.present_unchanged.len()
|
||||
);
|
||||
}
|
||||
let mut to_fetch: Vec<ItemId> = plan.new.clone();
|
||||
for (id, _local_id) in &plan.present_changed {
|
||||
to_fetch.push(id.clone());
|
||||
}
|
||||
if !to_fetch.is_empty() {
|
||||
let outcome = get_items(ctx, ItemShape::Message, &to_fetch).map_err(Error::from)?;
|
||||
counts.failed += outcome.failed_items;
|
||||
for msg in outcome.messages {
|
||||
if !msg.success {
|
||||
if matches!(
|
||||
msg.response_code,
|
||||
crate::exchange_ews::types::ResponseCode::ItemNotFound
|
||||
) {
|
||||
counts.skipped += 1;
|
||||
} else {
|
||||
counts.failed += 1;
|
||||
ctx.logger.warn(&format!(
|
||||
"GetItem (message) error: {} {}",
|
||||
msg.response_code, msg.message_text
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let parsed = parse_message_item(&msg.inner_xml).map_err(Error::from)?;
|
||||
if parsed.id.id.is_empty() {
|
||||
counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
let existing = plan
|
||||
.present_changed
|
||||
.iter()
|
||||
.find(|(id, _)| id.id == parsed.id.id)
|
||||
.map(|(_, local)| *local);
|
||||
apply_message(
|
||||
conn,
|
||||
ctx,
|
||||
&parsed,
|
||||
local_folder_id,
|
||||
&folder.id,
|
||||
existing,
|
||||
counts,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
delete_vanished(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::EMAIL,
|
||||
"emails",
|
||||
&plan.vanished,
|
||||
counts,
|
||||
)?;
|
||||
if let EnumerationMode::Delta { new_sync_state, .. } = &outcome.mode {
|
||||
exchange_ews_ids::set_sync_state(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::MAILBOX,
|
||||
&folder.id,
|
||||
new_sync_state,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_message(
|
||||
conn: &mut Connection,
|
||||
ctx: &ItemRunCtx<'_>,
|
||||
item: &MessageItem,
|
||||
local_folder_id: i64,
|
||||
folder_id: &str,
|
||||
existing_local_id: Option<i64>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let mime_b64 = match item.mime_content.as_ref() {
|
||||
Some(s) => s.replace(['\n', '\r', ' ', '\t'], ""),
|
||||
None => {
|
||||
counts.skipped += 1;
|
||||
ctx.logger.warn(&format!(
|
||||
"message {} has no MimeContent; skipping",
|
||||
item.id.id
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let bytes = match STANDARD.decode(mime_b64.as_bytes()) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
counts.failed += 1;
|
||||
ctx.logger.warn(&format!(
|
||||
"message {} MimeContent base64 decode failed: {e}",
|
||||
item.id.id
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let (idx, _date_header) = email_meta_from_blob(&bytes);
|
||||
let message_match = index_to_json(&idx);
|
||||
let received_at = item
|
||||
.date_time_received
|
||||
.clone()
|
||||
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned());
|
||||
let keywords = keyword_array(item);
|
||||
let mailbox_ids = json!([local_folder_id]).to_string();
|
||||
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let blob_id = blobs::intern_blob(&tx, &bytes).map_err(|e| Error::Partial(e.to_string()))?;
|
||||
match existing_local_id {
|
||||
Some(id) => {
|
||||
tx.execute(
|
||||
"UPDATE emails SET blob_id = ?1, received_at = ?2, mailbox_ids = ?3, \
|
||||
keywords = ?4, message_match = ?5 WHERE id = ?6",
|
||||
params![
|
||||
blob_id,
|
||||
received_at,
|
||||
mailbox_ids,
|
||||
keywords,
|
||||
message_match,
|
||||
id
|
||||
],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
exchange_ews_ids::update_change_key(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::EMAIL,
|
||||
&item.id.id,
|
||||
&item.id.change_key,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.fetched += 1;
|
||||
}
|
||||
None => {
|
||||
tx.execute(
|
||||
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords, message_match) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![blob_id, received_at, mailbox_ids, keywords, message_match],
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_ews_ids::insert(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_ews_ids::EMAIL,
|
||||
folder_id,
|
||||
&item.id.id,
|
||||
&item.id.change_key,
|
||||
new_id,
|
||||
)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
counts.created += 1;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keyword_array(item: &MessageItem) -> String {
|
||||
let mut kws: Vec<String> = Vec::new();
|
||||
if matches!(item.is_read, Some(true)) {
|
||||
kws.push("$seen".to_owned());
|
||||
}
|
||||
if matches!(item.is_draft, Some(true)) {
|
||||
kws.push("$draft".to_owned());
|
||||
}
|
||||
if matches!(item.is_read_receipt_requested, Some(true)) {
|
||||
kws.push("$notified".to_owned());
|
||||
}
|
||||
if matches!(item.flag_status.as_deref(), Some("Flagged")) {
|
||||
kws.push("$flagged".to_owned());
|
||||
}
|
||||
for cat in &item.categories {
|
||||
kws.push(cat.to_ascii_lowercase());
|
||||
}
|
||||
let value: Value = Value::Array(kws.into_iter().map(Value::String).collect());
|
||||
value.to_string()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod calendar;
|
||||
pub mod contacts;
|
||||
pub mod coordinator;
|
||||
pub mod folders;
|
||||
pub mod messages;
|
||||
|
||||
pub use coordinator::{GraphAuth, GraphImportConfig, run};
|
||||
@@ -0,0 +1,443 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{Connection, Transaction, params};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::exchange_graph_ids;
|
||||
use crate::error::Error;
|
||||
use crate::exchange_graph::api::{self, PREFER_BODY_HTML, PREFER_BODY_TEXT, PREFER_TIMEZONE_UTC};
|
||||
use crate::exchange_graph::calendar_map::{
|
||||
ConvertedEvent, EventType, classify_event_type, convert_event,
|
||||
};
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
use crate::exchange_graph::types::EventBodyFormat;
|
||||
use crate::logging::LEVEL_PROGRESS;
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::import_jmap::pool::Pool;
|
||||
|
||||
use super::coordinator::{CHUNK_SIZE, GraphCoordinator};
|
||||
use super::folders::CalendarFolder;
|
||||
|
||||
pub fn reconcile_all(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
calendars: &[CalendarFolder],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let local: HashMap<String, i64> =
|
||||
exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::CALENDAR_EVENT)?;
|
||||
let mut server_total: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut any_failure = false;
|
||||
|
||||
for cal in calendars {
|
||||
let url = ctx.endpoints.calendar_events_ids(&cal.graph_id, ctx.top);
|
||||
let stubs = match api::collect_all_values(ctx.client, &url, &[PREFER_TIMEZONE_UTC]) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
ctx.logger
|
||||
.warn(&format!("calendar {} stub fetch failed: {e}", cal.graph_id));
|
||||
counts.failed += 1;
|
||||
any_failure = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut want_ids: Vec<String> = Vec::new();
|
||||
let mut occurrence_count = 0usize;
|
||||
for stub in &stubs {
|
||||
match classify_event_type(stub) {
|
||||
EventType::Occurrence => occurrence_count += 1,
|
||||
_ => {
|
||||
if let Some(id) = stub.get("id").and_then(Value::as_str) {
|
||||
server_total.insert(id.to_owned());
|
||||
if !local.contains_key(id) {
|
||||
want_ids.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctx.logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"graph calendar {} events: stubs={} new={} occurrences_skipped={}",
|
||||
cal.graph_id,
|
||||
stubs.len(),
|
||||
want_ids.len(),
|
||||
occurrence_count
|
||||
);
|
||||
}
|
||||
if want_ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fetched = fetch_events(ctx, &want_ids);
|
||||
let mut masters: Vec<(String, ConvertedEvent)> = Vec::new();
|
||||
let mut exceptions: Vec<(String, ConvertedEvent)> = Vec::new();
|
||||
for (graph_id, result) in fetched {
|
||||
match result {
|
||||
Ok(raw) => match convert_event(&raw, None) {
|
||||
Ok(c) => match c.event_type {
|
||||
EventType::Exception => exceptions.push((graph_id, c)),
|
||||
_ => masters.push((graph_id, c)),
|
||||
},
|
||||
Err(e) => {
|
||||
counts.failed += 1;
|
||||
ctx.logger
|
||||
.warn(&format!("graph event {graph_id} convert failed: {e}"));
|
||||
}
|
||||
},
|
||||
Err(GraphError::Vanished) => counts.skipped += 1,
|
||||
Err(e) => {
|
||||
counts.failed += 1;
|
||||
ctx.logger
|
||||
.warn(&format!("graph event {graph_id} fetch failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut master_by_graph_id: HashMap<String, ConvertedEvent> = masters.into_iter().collect();
|
||||
for (ex_graph_id, ex) in exceptions {
|
||||
let Some(master_graph_id) = ex.series_master_id.clone() else {
|
||||
ctx.logger.warn(&format!(
|
||||
"graph exception event {ex_graph_id} has no seriesMasterId; dropping"
|
||||
));
|
||||
counts.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if let Some(master) = master_by_graph_id.get_mut(&master_graph_id) {
|
||||
merge_exception_into(master, &ex);
|
||||
} else if local.contains_key(&master_graph_id) {
|
||||
merge_exception_into_existing(conn, ctx, &master_graph_id, &ex, counts);
|
||||
} else {
|
||||
counts.skipped += 1;
|
||||
ctx.logger.warn(&format!(
|
||||
"graph exception {ex_graph_id} references missing master {master_graph_id}; orphaned"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let pairs: Vec<(String, ConvertedEvent)> = master_by_graph_id.into_iter().collect();
|
||||
insert_events_chunked(conn, ctx, cal.local_id, &pairs, counts)?;
|
||||
}
|
||||
|
||||
if any_failure {
|
||||
ctx.logger.warn(
|
||||
"graph event vanished-cleanup skipped: one or more calendars failed to enumerate; \
|
||||
a clean re-run will reconcile deletions",
|
||||
);
|
||||
} else {
|
||||
delete_vanished(conn, ctx.source_id, &local, &server_total, counts)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_events(
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
ids: &[String],
|
||||
) -> Vec<(String, Result<Value, GraphError>)> {
|
||||
type R = (String, Result<Value, GraphError>);
|
||||
let client = ctx.client.clone();
|
||||
let endpoints: crate::exchange_graph::api::Endpoints = (*ctx.endpoints).clone();
|
||||
let body_prefer = match ctx.event_body_format {
|
||||
EventBodyFormat::Text => PREFER_BODY_TEXT,
|
||||
EventBodyFormat::Html => PREFER_BODY_HTML,
|
||||
};
|
||||
let prefer: Vec<String> = vec![PREFER_TIMEZONE_UTC.to_owned(), body_prefer.to_owned()];
|
||||
let pool: Pool<String, R> = Pool::new(ctx.workers, move |id: String| {
|
||||
let url = endpoints.event(&id);
|
||||
let prefer_refs: Vec<&str> = prefer.iter().map(String::as_str).collect();
|
||||
let result = client.get_json_with_prefer(&url, &prefer_refs);
|
||||
(id, result)
|
||||
});
|
||||
for id in ids {
|
||||
pool.submit(id.clone());
|
||||
}
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for _ in 0..ids.len() {
|
||||
if let Ok(r) = pool.results().recv() {
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn merge_exception_into(master: &mut ConvertedEvent, ex: &ConvertedEvent) {
|
||||
let Some(raw_key) = ex.original_start.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let master_tz = master
|
||||
.data
|
||||
.get("timeZone")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
let key = normalise_override_key(raw_key, master_tz.as_deref());
|
||||
let Value::Object(map) = &mut master.data else {
|
||||
return;
|
||||
};
|
||||
let overrides = map
|
||||
.entry("recurrenceOverrides".to_owned())
|
||||
.or_insert_with(|| Value::Object(serde_json::Map::new()));
|
||||
let Value::Object(overrides) = overrides else {
|
||||
return;
|
||||
};
|
||||
overrides.insert(key, ex.data.clone());
|
||||
}
|
||||
|
||||
fn merge_exception_into_existing(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
master_graph_id: &str,
|
||||
ex: &ConvertedEvent,
|
||||
counts: &mut TypeCounts,
|
||||
) {
|
||||
let local_id = match exchange_graph_ids::local_for_graph_id(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_graph_ids::CALENDAR_EVENT,
|
||||
master_graph_id,
|
||||
) {
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => {
|
||||
counts.skipped += 1;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
counts.failed += 1;
|
||||
ctx.logger.warn(&format!(
|
||||
"graph exception merge: lookup of master {master_graph_id} failed: {e}"
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(raw_key) = ex.original_start.as_deref() else {
|
||||
counts.skipped += 1;
|
||||
return;
|
||||
};
|
||||
if let Err(e) = merge_persisted_master(conn, local_id, raw_key, &ex.data) {
|
||||
counts.failed += 1;
|
||||
ctx.logger.warn(&format!(
|
||||
"graph exception merge into stored master {master_graph_id} failed: {e}"
|
||||
));
|
||||
} else {
|
||||
counts.fetched += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_persisted_master(
|
||||
conn: &Connection,
|
||||
local_id: i64,
|
||||
raw_key: &str,
|
||||
ex_data: &Value,
|
||||
) -> Result<(), String> {
|
||||
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
|
||||
let row: String = tx
|
||||
.query_row(
|
||||
"SELECT data FROM calendar_events WHERE id = ?1",
|
||||
params![local_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut data: Value = serde_json::from_str(&row).map_err(|e| e.to_string())?;
|
||||
let master_tz = data
|
||||
.get("timeZone")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
let key = normalise_override_key(raw_key, master_tz.as_deref());
|
||||
if let Value::Object(map) = &mut data {
|
||||
let entry = map
|
||||
.entry("recurrenceOverrides".to_owned())
|
||||
.or_insert_with(|| Value::Object(serde_json::Map::new()));
|
||||
if let Value::Object(overrides) = entry {
|
||||
overrides.insert(key, ex_data.clone());
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE calendar_events SET data = ?1 WHERE id = ?2",
|
||||
params![data.to_string(), local_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn normalise_override_key(raw: &str, master_tz: Option<&str>) -> String {
|
||||
if let Some(tz_name) = master_tz
|
||||
&& let Some(local) = utc_offset_to_local(raw, tz_name)
|
||||
{
|
||||
return local;
|
||||
}
|
||||
strip_offset_and_fractional(raw)
|
||||
}
|
||||
|
||||
fn utc_offset_to_local(raw: &str, tz_name: &str) -> Option<String> {
|
||||
use chrono::{DateTime, NaiveDateTime, TimeZone};
|
||||
use chrono_tz::Tz;
|
||||
let tz: Tz = tz_name.parse().ok()?;
|
||||
let parsed: DateTime<chrono::FixedOffset> = if let Ok(dt) = DateTime::parse_from_rfc3339(raw) {
|
||||
dt
|
||||
} else if let Ok(naive) = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f") {
|
||||
return Some(format_local(naive));
|
||||
} else if let Ok(naive) = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") {
|
||||
return Some(format_local(naive));
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let local = tz.from_utc_datetime(&parsed.naive_utc()).naive_local();
|
||||
Some(format_local(local))
|
||||
}
|
||||
|
||||
fn format_local(dt: chrono::NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
fn strip_offset_and_fractional(raw: &str) -> String {
|
||||
let no_offset = if let Some(idx) = raw.find('Z') {
|
||||
&raw[..idx]
|
||||
} else if let Some(idx) = raw.find('+') {
|
||||
&raw[..idx]
|
||||
} else if let Some(idx) = raw.rfind('-')
|
||||
&& idx > 10
|
||||
{
|
||||
&raw[..idx]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let trimmed = no_offset.split('.').next().unwrap_or(no_offset);
|
||||
if trimmed.contains('T') {
|
||||
trimmed.to_owned()
|
||||
} else {
|
||||
format!("{trimmed}T00:00:00")
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_events_chunked(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
calendar_local_id: i64,
|
||||
pairs: &[(String, ConvertedEvent)],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
for chunk in pairs.chunks(CHUNK_SIZE) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for (graph_id, event) in chunk {
|
||||
insert_event_in_tx(&tx, ctx, calendar_local_id, graph_id, event, counts)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_event_in_tx(
|
||||
tx: &Transaction<'_>,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
calendar_local_id: i64,
|
||||
graph_id: &str,
|
||||
event: &ConvertedEvent,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let calendar_ids = json!([calendar_local_id]).to_string();
|
||||
let data = event.data.to_string();
|
||||
tx.execute(
|
||||
"INSERT INTO calendar_events (calendar_ids, is_draft, use_default_alerts, data, data_type)
|
||||
VALUES (?1, ?2, ?3, ?4, 'Event')",
|
||||
params![
|
||||
calendar_ids,
|
||||
event.is_draft as i64,
|
||||
event.use_default_alerts as i64,
|
||||
data,
|
||||
],
|
||||
)?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_graph_ids::insert(
|
||||
tx,
|
||||
ctx.source_id,
|
||||
exchange_graph_ids::CALENDAR_EVENT,
|
||||
graph_id,
|
||||
new_id,
|
||||
)?;
|
||||
counts.created += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_vanished(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
local: &HashMap<String, i64>,
|
||||
server: &std::collections::HashSet<String>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let vanished: Vec<(&String, &i64)> = local
|
||||
.iter()
|
||||
.filter(|(graph_id, _)| !server.contains(graph_id.as_str()))
|
||||
.collect();
|
||||
for chunk in vanished.chunks(CHUNK_SIZE) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for (graph_id, local_id) in chunk {
|
||||
let result = tx.execute(
|
||||
"DELETE FROM calendar_events WHERE id = ?1",
|
||||
params![local_id],
|
||||
);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
exchange_graph_ids::delete(
|
||||
&tx,
|
||||
source_id,
|
||||
exchange_graph_ids::CALENDAR_EVENT,
|
||||
graph_id,
|
||||
)?;
|
||||
counts.deleted += 1;
|
||||
}
|
||||
Err(_) => {
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn override_key_utc_converts_to_master_local_time() {
|
||||
let local = normalise_override_key("2026-05-11T15:00:00Z", Some("America/New_York"));
|
||||
assert_eq!(local, "2026-05-11T11:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_key_no_tz_strips_z_only() {
|
||||
let local = normalise_override_key("2026-05-11T15:00:00Z", None);
|
||||
assert_eq!(local, "2026-05-11T15:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_key_with_explicit_offset_converts() {
|
||||
let local = normalise_override_key("2026-05-11T15:00:00+00:00", Some("Europe/London"));
|
||||
assert_eq!(local, "2026-05-11T16:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_key_naive_localdatetime_passes_through() {
|
||||
let local = normalise_override_key("2026-05-11T15:00:00", Some("America/New_York"));
|
||||
assert_eq!(local, "2026-05-11T15:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_key_unknown_timezone_strips_offset_only() {
|
||||
let local = normalise_override_key("2026-05-11T15:00:00Z", Some("Not/A_Zone"));
|
||||
assert_eq!(local, "2026-05-11T15:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_key_fractional_seconds_stripped() {
|
||||
let local = normalise_override_key("2026-05-11T15:00:00.1234567Z", None);
|
||||
assert_eq!(local, "2026-05-11T15:00:00");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{Connection, Transaction, params};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::exchange_graph_ids;
|
||||
use crate::error::Error;
|
||||
use crate::exchange_graph::api;
|
||||
use crate::exchange_graph::contact_map::{ConvertedContact, convert_contact};
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
use crate::logging::LEVEL_PROGRESS;
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::import_jmap::pool::Pool;
|
||||
|
||||
use super::coordinator::{CHUNK_SIZE, GraphCoordinator};
|
||||
use super::folders::ContactFolder;
|
||||
|
||||
pub fn reconcile_all(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
books: &[ContactFolder],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let local: HashMap<String, i64> =
|
||||
exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::CONTACT_CARD)?;
|
||||
let mut server_total: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut any_failure = false;
|
||||
|
||||
for book in books {
|
||||
let url = ctx
|
||||
.endpoints
|
||||
.contact_folder_contacts_ids(&book.graph_id, ctx.top);
|
||||
let ids = match api::collect_all_ids(ctx.client, &url, &[]) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
ctx.logger.warn(&format!(
|
||||
"contact folder {} enumeration failed: {e}",
|
||||
book.graph_id
|
||||
));
|
||||
counts.failed += 1;
|
||||
any_failure = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if ctx.logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"graph contact folder {} contacts: {}",
|
||||
book.graph_id,
|
||||
ids.len()
|
||||
);
|
||||
}
|
||||
for id in &ids {
|
||||
server_total.insert(id.clone());
|
||||
}
|
||||
let new_ids: Vec<String> = ids
|
||||
.into_iter()
|
||||
.filter(|id| !local.contains_key(id))
|
||||
.collect();
|
||||
if new_ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fetched = fetch_contacts(ctx, &new_ids);
|
||||
let mut converted: Vec<(String, ConvertedContact)> = Vec::new();
|
||||
for (graph_id, result) in fetched {
|
||||
match result {
|
||||
Ok(raw) => match convert_contact(&raw) {
|
||||
Ok(c) => converted.push((graph_id, c)),
|
||||
Err(e) => {
|
||||
counts.failed += 1;
|
||||
ctx.logger
|
||||
.warn(&format!("graph contact {graph_id} convert failed: {e}"));
|
||||
}
|
||||
},
|
||||
Err(GraphError::Vanished) => counts.skipped += 1,
|
||||
Err(e) => {
|
||||
counts.failed += 1;
|
||||
ctx.logger
|
||||
.warn(&format!("graph contact {graph_id} fetch failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
insert_contacts_chunked(conn, ctx, book.local_id, &converted, counts)?;
|
||||
}
|
||||
|
||||
if any_failure {
|
||||
ctx.logger.warn(
|
||||
"graph contact vanished-cleanup skipped: one or more contact folders failed to enumerate; \
|
||||
a clean re-run will reconcile deletions",
|
||||
);
|
||||
} else {
|
||||
delete_vanished(conn, ctx.source_id, &local, &server_total, counts)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_contacts(
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
ids: &[String],
|
||||
) -> Vec<(String, Result<Value, GraphError>)> {
|
||||
type R = (String, Result<Value, GraphError>);
|
||||
let client = ctx.client.clone();
|
||||
let endpoints: crate::exchange_graph::api::Endpoints = (*ctx.endpoints).clone();
|
||||
let pool: Pool<String, R> = Pool::new(ctx.workers, move |id: String| {
|
||||
let url = endpoints.contact(&id);
|
||||
(id, client.get_json_with_prefer(&url, &[]))
|
||||
});
|
||||
for id in ids {
|
||||
pool.submit(id.clone());
|
||||
}
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for _ in 0..ids.len() {
|
||||
if let Ok(r) = pool.results().recv() {
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn insert_contacts_chunked(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
book_local_id: i64,
|
||||
pairs: &[(String, ConvertedContact)],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
for chunk in pairs.chunks(CHUNK_SIZE) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for (graph_id, contact) in chunk {
|
||||
insert_contact_in_tx(&tx, ctx, book_local_id, graph_id, contact, counts)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_contact_in_tx(
|
||||
tx: &Transaction<'_>,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
book_local_id: i64,
|
||||
graph_id: &str,
|
||||
converted: &ConvertedContact,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let book_ids = json!([book_local_id]).to_string();
|
||||
let data = converted.data.to_string();
|
||||
tx.execute(
|
||||
"INSERT INTO contact_cards (uid, address_book_ids, data) VALUES (?1, ?2, ?3)",
|
||||
params![converted.uid, book_ids, data],
|
||||
)?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_graph_ids::insert(
|
||||
tx,
|
||||
ctx.source_id,
|
||||
exchange_graph_ids::CONTACT_CARD,
|
||||
graph_id,
|
||||
new_id,
|
||||
)?;
|
||||
counts.created += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_vanished(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
local: &HashMap<String, i64>,
|
||||
server: &std::collections::HashSet<String>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let vanished: Vec<(&String, &i64)> = local
|
||||
.iter()
|
||||
.filter(|(graph_id, _)| !server.contains(graph_id.as_str()))
|
||||
.collect();
|
||||
for chunk in vanished.chunks(CHUNK_SIZE) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for (graph_id, local_id) in chunk {
|
||||
let result = tx.execute("DELETE FROM contact_cards WHERE id = ?1", params![local_id]);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
exchange_graph_ids::delete(
|
||||
&tx,
|
||||
source_id,
|
||||
exchange_graph_ids::CONTACT_CARD,
|
||||
graph_id,
|
||||
)?;
|
||||
counts.deleted += 1;
|
||||
}
|
||||
Err(_) => {
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::db;
|
||||
use crate::db::sources::SourceKey;
|
||||
use crate::error::Error;
|
||||
use crate::exchange_graph::api::{DEFAULT_API_BASE, Endpoints};
|
||||
use crate::exchange_graph::client::GraphClient;
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
use crate::exchange_graph::oauth::{
|
||||
AcquiredToken, OAuthFlow, acquire, default_authority, refresh_access_token,
|
||||
};
|
||||
use crate::exchange_graph::types::{EventBodyFormat, MailboxKind, synthetic_account_id};
|
||||
use crate::jmap::http::RetryPolicy;
|
||||
use crate::logging::LEVEL_DEFAULT;
|
||||
use crate::sync::{CommonConfig, Summary, TypeCounts};
|
||||
use crate::types::ObjectType;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GraphAuth {
|
||||
PreAcquired {
|
||||
token: String,
|
||||
},
|
||||
DeviceCode {
|
||||
authority: String,
|
||||
client_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraphImportConfig {
|
||||
pub auth: GraphAuth,
|
||||
pub api_base: String,
|
||||
pub user_target: Option<String>,
|
||||
pub mailbox_kind: MailboxKind,
|
||||
pub objects: Option<Vec<ObjectType>>,
|
||||
pub event_body_format: EventBodyFormat,
|
||||
pub graph_connections: usize,
|
||||
pub top: usize,
|
||||
pub allow_source_change: bool,
|
||||
}
|
||||
|
||||
pub const CHUNK_SIZE: usize = 100;
|
||||
|
||||
pub struct GraphCoordinator<'a> {
|
||||
pub client: &'a GraphClient,
|
||||
pub endpoints: &'a Endpoints,
|
||||
pub source_id: i64,
|
||||
pub top: usize,
|
||||
pub workers: usize,
|
||||
pub logger: crate::logging::Logger,
|
||||
pub event_body_format: EventBodyFormat,
|
||||
}
|
||||
|
||||
pub fn run(common: CommonConfig, config: GraphImportConfig) -> Result<Summary, Error> {
|
||||
let logger = common.logger;
|
||||
let mut conn = db::init::open(&common.archive)?;
|
||||
|
||||
let acquired = acquire_with_flow(&config.auth, common.allow_invalid_certs)?;
|
||||
let client = GraphClient::new(
|
||||
acquired.access_token.clone(),
|
||||
RetryPolicy::new(common.max_retries),
|
||||
common.allow_invalid_certs,
|
||||
);
|
||||
client.set_logger(logger);
|
||||
|
||||
let endpoints = resolve_endpoints(&config, &client)?;
|
||||
let principal = resolve_principal(&client, &endpoints, acquired.upn.as_deref())?;
|
||||
|
||||
let session_url = canonical_session_url(&config);
|
||||
let account_id = synthetic_account_id(&principal.id, config.mailbox_kind);
|
||||
|
||||
if !common.dry_run
|
||||
&& let Some((url, acc)) =
|
||||
db::sources::conflicting_source(&conn, "exchange_graph", &session_url, &account_id)?
|
||||
&& !config.allow_source_change
|
||||
{
|
||||
return Err(Error::SourceChange(format!(
|
||||
"archive already records exchange_graph source ({url}, account {acc}); \
|
||||
pass --allow-source-change to import a different account"
|
||||
)));
|
||||
}
|
||||
|
||||
if matches!(config.mailbox_kind, MailboxKind::Archive) {
|
||||
logger.warn(
|
||||
"--mailbox-kind archive: Microsoft Online Archive holds only mail; \
|
||||
calendar and contact surfaces are skipped for this run",
|
||||
);
|
||||
}
|
||||
|
||||
if common.dry_run {
|
||||
return run_dry(
|
||||
&client,
|
||||
&endpoints,
|
||||
config.mailbox_kind,
|
||||
&logger,
|
||||
config.top,
|
||||
);
|
||||
}
|
||||
|
||||
let source_id = db::sources::upsert_source(
|
||||
&conn,
|
||||
&SourceKey {
|
||||
kind: "exchange_graph".to_owned(),
|
||||
session_url: session_url.clone(),
|
||||
account_id: account_id.clone(),
|
||||
},
|
||||
Some(&principal.user_principal_name),
|
||||
&principal.user_principal_name,
|
||||
)?;
|
||||
|
||||
let _refresher = spawn_token_refresher(
|
||||
&client,
|
||||
&config.auth,
|
||||
&acquired,
|
||||
common.allow_invalid_certs,
|
||||
logger,
|
||||
);
|
||||
|
||||
let mut summary = Summary::default();
|
||||
let mut mailbox_counts = TypeCounts::default();
|
||||
let mut email_counts = TypeCounts::default();
|
||||
let mut calendar_counts = TypeCounts::default();
|
||||
let mut event_counts = TypeCounts::default();
|
||||
let mut addressbook_counts = TypeCounts::default();
|
||||
let mut contact_counts = TypeCounts::default();
|
||||
|
||||
let ctx = GraphCoordinator {
|
||||
client: &client,
|
||||
endpoints: &endpoints,
|
||||
source_id,
|
||||
top: config.top.clamp(1, 1000),
|
||||
workers: config.graph_connections.clamp(1, 16),
|
||||
logger,
|
||||
event_body_format: config.event_body_format,
|
||||
};
|
||||
|
||||
let want_mail = config
|
||||
.objects
|
||||
.as_ref()
|
||||
.map(|set| {
|
||||
set.iter()
|
||||
.any(|o| matches!(o, ObjectType::Mailbox | ObjectType::Email))
|
||||
})
|
||||
.unwrap_or(true);
|
||||
let want_calendar = config
|
||||
.objects
|
||||
.as_ref()
|
||||
.map(|set| {
|
||||
set.iter()
|
||||
.any(|o| matches!(o, ObjectType::Calendar | ObjectType::CalendarEvent))
|
||||
})
|
||||
.unwrap_or(true)
|
||||
&& !matches!(config.mailbox_kind, MailboxKind::Archive);
|
||||
let want_contacts = config
|
||||
.objects
|
||||
.as_ref()
|
||||
.map(|set| {
|
||||
set.iter()
|
||||
.any(|o| matches!(o, ObjectType::AddressBook | ObjectType::ContactCard))
|
||||
})
|
||||
.unwrap_or(true)
|
||||
&& !matches!(config.mailbox_kind, MailboxKind::Archive);
|
||||
|
||||
if want_mail {
|
||||
let folders = super::folders::reconcile_mail(
|
||||
&mut conn,
|
||||
&ctx,
|
||||
config.mailbox_kind,
|
||||
&mut mailbox_counts,
|
||||
)?;
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!(
|
||||
"graph mail folders: created={} fetched={} deleted={}",
|
||||
mailbox_counts.created, mailbox_counts.fetched, mailbox_counts.deleted
|
||||
);
|
||||
}
|
||||
super::messages::reconcile_all(&mut conn, &ctx, &folders, &mut email_counts)?;
|
||||
}
|
||||
|
||||
if want_calendar {
|
||||
let calendars = super::folders::reconcile_calendars(&mut conn, &ctx, &mut calendar_counts)?;
|
||||
super::calendar::reconcile_all(&mut conn, &ctx, &calendars, &mut event_counts)?;
|
||||
}
|
||||
|
||||
if want_contacts {
|
||||
let books =
|
||||
super::folders::reconcile_address_books(&mut conn, &ctx, &mut addressbook_counts)?;
|
||||
super::contacts::reconcile_all(&mut conn, &ctx, &books, &mut contact_counts)?;
|
||||
}
|
||||
|
||||
summary.per_type.push(("mailbox", mailbox_counts));
|
||||
summary.per_type.push(("email", email_counts));
|
||||
summary.per_type.push(("calendar", calendar_counts));
|
||||
summary.per_type.push(("calendarevent", event_counts));
|
||||
summary.per_type.push(("addressbook", addressbook_counts));
|
||||
summary.per_type.push(("contactcard", contact_counts));
|
||||
|
||||
if !summary.any_failed()
|
||||
&& let Err(e) = run_gc(&conn)
|
||||
{
|
||||
logger.warn(&format!("blob GC skipped: {e}"));
|
||||
}
|
||||
|
||||
summary.retries_observed = client.retries_observed();
|
||||
summary.retry_after_sleeps = client.retry_after_sleeps();
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn run_dry(
|
||||
client: &GraphClient,
|
||||
endpoints: &Endpoints,
|
||||
mailbox_kind: MailboxKind,
|
||||
logger: &crate::logging::Logger,
|
||||
top: usize,
|
||||
) -> Result<Summary, Error> {
|
||||
let mut mailbox_counts = TypeCounts::default();
|
||||
let mut calendar_counts = TypeCounts::default();
|
||||
let mut addressbook_counts = TypeCounts::default();
|
||||
let folders =
|
||||
enumerate_mail_folders(client, endpoints, mailbox_kind, top).map_err(Error::from)?;
|
||||
mailbox_counts.created = folders.len() as u64;
|
||||
if !matches!(mailbox_kind, MailboxKind::Archive) {
|
||||
let calendars =
|
||||
crate::exchange_graph::api::collect_all_values(client, &endpoints.calendars(top), &[])
|
||||
.map_err(Error::from)?;
|
||||
calendar_counts.created = calendars.len() as u64;
|
||||
let books = crate::exchange_graph::api::collect_all_values(
|
||||
client,
|
||||
&endpoints.contact_folders(top),
|
||||
&[],
|
||||
)
|
||||
.map_err(Error::from)?;
|
||||
addressbook_counts.created = books.len() as u64;
|
||||
}
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!(
|
||||
"dry-run: mailbox={} calendar={} addressbook={}",
|
||||
mailbox_counts.created, calendar_counts.created, addressbook_counts.created
|
||||
);
|
||||
}
|
||||
let mut summary = Summary::default();
|
||||
summary.per_type.push(("mailbox", mailbox_counts));
|
||||
summary.per_type.push(("calendar", calendar_counts));
|
||||
summary.per_type.push(("addressbook", addressbook_counts));
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
pub fn enumerate_mail_folders(
|
||||
client: &GraphClient,
|
||||
endpoints: &Endpoints,
|
||||
mailbox_kind: MailboxKind,
|
||||
top: usize,
|
||||
) -> Result<Vec<Value>, GraphError> {
|
||||
let mut all = Vec::new();
|
||||
let mut frontier: Vec<String> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let initial = endpoints.mail_folders_root(mailbox_kind, top);
|
||||
let level = crate::exchange_graph::api::collect_all_values(client, &initial, &[])?;
|
||||
for f in &level {
|
||||
if let Some(id) = f.get("id").and_then(Value::as_str)
|
||||
&& seen.insert(id.to_owned())
|
||||
{
|
||||
frontier.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
all.extend(level);
|
||||
while let Some(parent) = frontier.pop() {
|
||||
let url = endpoints.mail_folder_child_folders(&parent, top);
|
||||
let children = crate::exchange_graph::api::collect_all_values(client, &url, &[])?;
|
||||
for c in &children {
|
||||
if let Some(id) = c.get("id").and_then(Value::as_str)
|
||||
&& seen.insert(id.to_owned())
|
||||
{
|
||||
frontier.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
all.extend(children);
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
fn acquire_with_flow(auth: &GraphAuth, allow_invalid_certs: bool) -> Result<AcquiredToken, Error> {
|
||||
let flow = match auth {
|
||||
GraphAuth::PreAcquired { token } => OAuthFlow::PreAcquired {
|
||||
token: token.clone(),
|
||||
},
|
||||
GraphAuth::DeviceCode {
|
||||
authority,
|
||||
client_id,
|
||||
} => OAuthFlow::DeviceCode {
|
||||
authority: authority.clone(),
|
||||
client_id: client_id.clone(),
|
||||
},
|
||||
};
|
||||
acquire(&flow, allow_invalid_certs).map_err(Error::from)
|
||||
}
|
||||
|
||||
fn resolve_endpoints(config: &GraphImportConfig, client: &GraphClient) -> Result<Endpoints, Error> {
|
||||
if let Some(target) = config.user_target.as_deref() {
|
||||
let resolved = if looks_like_uuid(target) {
|
||||
target.to_owned()
|
||||
} else {
|
||||
resolve_user_id(client, &config.api_base, target)
|
||||
.map_err(|e| Error::Connection(format!("user resolution: {e}")))?
|
||||
};
|
||||
Ok(Endpoints::for_user(&config.api_base, &resolved))
|
||||
} else {
|
||||
Ok(Endpoints::for_me(&config.api_base))
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_uuid(s: &str) -> bool {
|
||||
s.len() == 36
|
||||
&& s.chars().enumerate().all(|(i, c)| match i {
|
||||
8 | 13 | 18 | 23 => c == '-',
|
||||
_ => c.is_ascii_hexdigit(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_user_id(
|
||||
client: &GraphClient,
|
||||
api_base: &str,
|
||||
upn_or_id: &str,
|
||||
) -> Result<String, GraphError> {
|
||||
let url = format!(
|
||||
"{}/users/{}?$select=id,userPrincipalName",
|
||||
api_base.trim_end_matches('/'),
|
||||
upn_or_id
|
||||
);
|
||||
let body = client.get_json(&url)?;
|
||||
let id = body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| GraphError::Malformed("user resolution missing id".to_owned()))?
|
||||
.to_owned();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn resolve_principal(
|
||||
client: &GraphClient,
|
||||
endpoints: &Endpoints,
|
||||
fallback_upn: Option<&str>,
|
||||
) -> Result<crate::exchange_graph::ResolvedPrincipal, Error> {
|
||||
let url = endpoints.me_select_id_upn();
|
||||
let value = client.get_json(&url).map_err(Error::from)?;
|
||||
let id = value
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Connection("graph principal has no id".to_owned()))?
|
||||
.to_owned();
|
||||
let upn = value
|
||||
.get("userPrincipalName")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.or_else(|| fallback_upn.map(str::to_owned))
|
||||
.unwrap_or_default();
|
||||
Ok(crate::exchange_graph::ResolvedPrincipal {
|
||||
id,
|
||||
user_principal_name: upn,
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_session_url(config: &GraphImportConfig) -> String {
|
||||
let authority = match &config.auth {
|
||||
GraphAuth::DeviceCode { authority, .. } => authority.clone(),
|
||||
GraphAuth::PreAcquired { .. } => default_authority("common"),
|
||||
};
|
||||
format!(
|
||||
"{}|{}",
|
||||
authority.trim_end_matches('/'),
|
||||
config.api_base.trim_end_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
fn spawn_token_refresher(
|
||||
client: &GraphClient,
|
||||
auth: &GraphAuth,
|
||||
initial: &AcquiredToken,
|
||||
allow_invalid_certs: bool,
|
||||
logger: crate::logging::Logger,
|
||||
) -> Option<TokenRefresher> {
|
||||
let (authority, client_id) = match auth {
|
||||
GraphAuth::DeviceCode {
|
||||
authority,
|
||||
client_id,
|
||||
} => (authority.clone(), client_id.clone()),
|
||||
GraphAuth::PreAcquired { .. } => return None,
|
||||
};
|
||||
let refresh = initial.refresh_token.clone()?;
|
||||
let mut deadline_unix = initial_deadline_unix(initial)?;
|
||||
let client = client.clone();
|
||||
let mut refresh_token = refresh;
|
||||
let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let shutdown_signal = shutdown.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("vandelay-graph-token-refresh".to_owned())
|
||||
.spawn(move || {
|
||||
while !shutdown_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
let now = unix_now();
|
||||
let refresh_at = deadline_unix.saturating_sub(60);
|
||||
if refresh_at > now {
|
||||
let wait = std::time::Duration::from_secs(refresh_at - now);
|
||||
let chunk = std::time::Duration::from_secs(2);
|
||||
let mut left = wait;
|
||||
while left > std::time::Duration::ZERO
|
||||
&& !shutdown_signal.load(std::sync::atomic::Ordering::Relaxed)
|
||||
{
|
||||
let step = chunk.min(left);
|
||||
std::thread::sleep(step);
|
||||
left = left.saturating_sub(step);
|
||||
}
|
||||
if shutdown_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match refresh_access_token(
|
||||
&authority,
|
||||
&client_id,
|
||||
&refresh_token,
|
||||
allow_invalid_certs,
|
||||
) {
|
||||
Ok(tok) => {
|
||||
client.set_bearer(tok.access_token.clone());
|
||||
if let Some(new_refresh) = tok.refresh_token {
|
||||
refresh_token = new_refresh;
|
||||
}
|
||||
deadline_unix = unix_now()
|
||||
+ tok.expires_in.unwrap_or_else(|| {
|
||||
crate::exchange_graph::oauth::decode_jwt_claims(&tok.access_token)
|
||||
.and_then(|c| c.exp)
|
||||
.and_then(|exp| exp.checked_sub(unix_now()))
|
||||
.unwrap_or(50 * 60)
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"graph token refresh failed: {e}; sleeping 60s before retry"
|
||||
));
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
Some(TokenRefresher {
|
||||
shutdown,
|
||||
handle: Some(handle),
|
||||
})
|
||||
}
|
||||
|
||||
pub struct TokenRefresher {
|
||||
shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for TokenRefresher {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
if let Some(h) = self.handle.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn initial_deadline_unix(initial: &AcquiredToken) -> Option<u64> {
|
||||
if let Some(expires_in) = initial.expires_in {
|
||||
return Some(unix_now() + expires_in);
|
||||
}
|
||||
crate::exchange_graph::oauth::decode_jwt_claims(&initial.access_token).and_then(|c| c.exp)
|
||||
}
|
||||
|
||||
fn run_gc(conn: &Connection) -> Result<(), Error> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
db::blobs::gc_orphan_blobs(&tx)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn default_api_base() -> String {
|
||||
DEFAULT_API_BASE.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_session_url_concatenates_authority_and_base() {
|
||||
let config = GraphImportConfig {
|
||||
auth: GraphAuth::DeviceCode {
|
||||
authority: "https://login.microsoftonline.com/common".to_owned(),
|
||||
client_id: "uuid".to_owned(),
|
||||
},
|
||||
api_base: "https://graph.microsoft.com/v1.0".to_owned(),
|
||||
user_target: None,
|
||||
mailbox_kind: MailboxKind::Primary,
|
||||
objects: None,
|
||||
event_body_format: EventBodyFormat::Text,
|
||||
graph_connections: 4,
|
||||
top: 100,
|
||||
allow_source_change: false,
|
||||
};
|
||||
let url = canonical_session_url(&config);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://login.microsoftonline.com/common|https://graph.microsoft.com/v1.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_uuid_only_matches_well_formed() {
|
||||
assert!(looks_like_uuid("12345678-1234-1234-1234-123456789abc"));
|
||||
assert!(!looks_like_uuid("[email protected]"));
|
||||
assert!(!looks_like_uuid("12345678123412341234123456789abc"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::db::exchange_graph_ids;
|
||||
use crate::error::Error;
|
||||
use crate::exchange_graph::api;
|
||||
use crate::exchange_graph::calendar_map::{graph_calendar_color_to_hex, windows_or_iana_to_iana};
|
||||
use crate::exchange_graph::types::MailboxKind;
|
||||
use crate::logging::LEVEL_PROGRESS;
|
||||
use crate::sync::TypeCounts;
|
||||
|
||||
use super::coordinator::{CHUNK_SIZE, GraphCoordinator, enumerate_mail_folders};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MailFolder {
|
||||
pub graph_id: String,
|
||||
pub parent_graph_id: Option<String>,
|
||||
pub display_name: String,
|
||||
pub is_hidden: bool,
|
||||
pub local_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CalendarFolder {
|
||||
pub graph_id: String,
|
||||
pub local_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContactFolder {
|
||||
pub graph_id: String,
|
||||
pub local_id: i64,
|
||||
}
|
||||
|
||||
pub fn reconcile_mail(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
mailbox_kind: MailboxKind,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<Vec<MailFolder>, Error> {
|
||||
let server = enumerate_mail_folders(ctx.client, ctx.endpoints, mailbox_kind, ctx.top)
|
||||
.map_err(Error::from)?;
|
||||
if ctx.logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!("graph mailFolders enumerated: {}", server.len());
|
||||
}
|
||||
let well_known = match mailbox_kind {
|
||||
MailboxKind::Primary => resolve_well_known_roles(ctx),
|
||||
MailboxKind::Archive => HashMap::new(),
|
||||
};
|
||||
let local: HashMap<String, i64> =
|
||||
exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::MAILBOX)?;
|
||||
|
||||
let entries = order_by_parent(server);
|
||||
let mut by_id: HashMap<String, i64> = HashMap::new();
|
||||
let mut out: Vec<MailFolder> = Vec::new();
|
||||
let mut server_ids: Vec<String> = Vec::new();
|
||||
|
||||
for chunk in entries.chunks(CHUNK_SIZE) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for value in chunk {
|
||||
let Some(graph_id) = value.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let parent_graph_id = value
|
||||
.get("parentFolderId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
let display_name = value
|
||||
.get("displayName")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Unnamed")
|
||||
.to_owned();
|
||||
let is_hidden = value
|
||||
.get("isHidden")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let role = well_known.get(graph_id).copied();
|
||||
let parent_local_id = parent_graph_id
|
||||
.as_deref()
|
||||
.and_then(|p| by_id.get(p))
|
||||
.copied();
|
||||
let existing = local.get(graph_id).copied();
|
||||
server_ids.push(graph_id.to_owned());
|
||||
|
||||
let local_id = if let Some(id) = existing {
|
||||
tx.execute(
|
||||
"UPDATE mailboxes SET name = ?1, parent_id = ?2, role = ?3, is_subscribed = ?4
|
||||
WHERE id = ?5",
|
||||
params![display_name, parent_local_id, role, !is_hidden as i64, id,],
|
||||
)?;
|
||||
counts.fetched += 1;
|
||||
id
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
|
||||
VALUES (?1, ?2, ?3, 0, ?4)",
|
||||
params![display_name, parent_local_id, role, !is_hidden as i64],
|
||||
)?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_graph_ids::insert(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_graph_ids::MAILBOX,
|
||||
graph_id,
|
||||
new_id,
|
||||
)?;
|
||||
counts.created += 1;
|
||||
new_id
|
||||
};
|
||||
by_id.insert(graph_id.to_owned(), local_id);
|
||||
out.push(MailFolder {
|
||||
graph_id: graph_id.to_owned(),
|
||||
parent_graph_id,
|
||||
display_name,
|
||||
is_hidden,
|
||||
local_id,
|
||||
});
|
||||
}
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
delete_vanished_mailboxes(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
&local,
|
||||
&server_ids,
|
||||
counts,
|
||||
&ctx.logger,
|
||||
)?;
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn reconcile_calendars(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<Vec<CalendarFolder>, Error> {
|
||||
let server = api::collect_all_values(ctx.client, &ctx.endpoints.calendars(ctx.top), &[])
|
||||
.map_err(Error::from)?;
|
||||
let mailbox_tz = mailbox_timezone(ctx);
|
||||
let local: HashMap<String, i64> =
|
||||
exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::CALENDAR)?;
|
||||
let mut out: Vec<CalendarFolder> = Vec::new();
|
||||
let mut server_ids: Vec<String> = Vec::new();
|
||||
|
||||
for chunk in server.chunks(CHUNK_SIZE) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for value in chunk {
|
||||
let Some(graph_id) = value.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
server_ids.push(graph_id.to_owned());
|
||||
let name = value
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Calendar")
|
||||
.to_owned();
|
||||
let color = value
|
||||
.get("hexColor")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("color")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(graph_calendar_color_to_hex)
|
||||
.map(str::to_owned)
|
||||
});
|
||||
let is_default = value
|
||||
.get("isDefaultCalendar")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let existing = local.get(graph_id).copied();
|
||||
let tz = mailbox_tz.clone();
|
||||
let local_id = if let Some(id) = existing {
|
||||
tx.execute(
|
||||
"UPDATE calendars SET name = ?1, color = ?2, is_default = ?3, time_zone = ?4
|
||||
WHERE id = ?5",
|
||||
params![name, color, is_default as i64, tz, id],
|
||||
)?;
|
||||
counts.fetched += 1;
|
||||
id
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO calendars (name, color, sort_order, is_subscribed, is_visible,
|
||||
is_default, include_in_availability, time_zone)
|
||||
VALUES (?1, ?2, 0, 1, 1, ?3, 'all', ?4)",
|
||||
params![name, color, is_default as i64, tz],
|
||||
)?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_graph_ids::insert(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_graph_ids::CALENDAR,
|
||||
graph_id,
|
||||
new_id,
|
||||
)?;
|
||||
counts.created += 1;
|
||||
new_id
|
||||
};
|
||||
out.push(CalendarFolder {
|
||||
graph_id: graph_id.to_owned(),
|
||||
local_id,
|
||||
});
|
||||
}
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
delete_vanished_flat(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_graph_ids::CALENDAR,
|
||||
"calendars",
|
||||
&local,
|
||||
&server_ids,
|
||||
counts,
|
||||
)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn reconcile_address_books(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<Vec<ContactFolder>, Error> {
|
||||
let mut server =
|
||||
api::collect_all_values(ctx.client, &ctx.endpoints.contact_folders(ctx.top), &[])
|
||||
.map_err(Error::from)?;
|
||||
let mut seen: std::collections::HashSet<String> = server
|
||||
.iter()
|
||||
.filter_map(|f| f.get("id").and_then(Value::as_str))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mut frontier: Vec<String> = seen.iter().cloned().collect();
|
||||
while let Some(parent) = frontier.pop() {
|
||||
let url = ctx.endpoints.contact_folder_children(&parent, ctx.top);
|
||||
let children = api::collect_all_values(ctx.client, &url, &[]).map_err(Error::from)?;
|
||||
for c in &children {
|
||||
if let Some(id) = c.get("id").and_then(Value::as_str)
|
||||
&& seen.insert(id.to_owned())
|
||||
{
|
||||
frontier.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
server.extend(children);
|
||||
}
|
||||
|
||||
let local: HashMap<String, i64> =
|
||||
exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::ADDRESS_BOOK)?;
|
||||
let mut out: Vec<ContactFolder> = Vec::new();
|
||||
let mut server_ids: Vec<String> = Vec::new();
|
||||
for chunk in server.chunks(CHUNK_SIZE) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for value in chunk {
|
||||
let Some(graph_id) = value.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
server_ids.push(graph_id.to_owned());
|
||||
let name = value
|
||||
.get("displayName")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Contacts")
|
||||
.to_owned();
|
||||
let existing = local.get(graph_id).copied();
|
||||
let local_id = if let Some(id) = existing {
|
||||
tx.execute(
|
||||
"UPDATE address_books SET name = ?1 WHERE id = ?2",
|
||||
params![name, id],
|
||||
)?;
|
||||
counts.fetched += 1;
|
||||
id
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO address_books (name, sort_order, is_subscribed)
|
||||
VALUES (?1, 0, 1)",
|
||||
params![name],
|
||||
)?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_graph_ids::insert(
|
||||
&tx,
|
||||
ctx.source_id,
|
||||
exchange_graph_ids::ADDRESS_BOOK,
|
||||
graph_id,
|
||||
new_id,
|
||||
)?;
|
||||
counts.created += 1;
|
||||
new_id
|
||||
};
|
||||
out.push(ContactFolder {
|
||||
graph_id: graph_id.to_owned(),
|
||||
local_id,
|
||||
});
|
||||
}
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
delete_vanished_flat(
|
||||
conn,
|
||||
ctx.source_id,
|
||||
exchange_graph_ids::ADDRESS_BOOK,
|
||||
"address_books",
|
||||
&local,
|
||||
&server_ids,
|
||||
counts,
|
||||
)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn mailbox_timezone(ctx: &GraphCoordinator<'_>) -> Option<String> {
|
||||
let url = ctx.endpoints.mailbox_settings_timezone();
|
||||
let value = ctx.client.get_json(&url).ok()?;
|
||||
let tz = value.get("timeZone").and_then(Value::as_str)?;
|
||||
windows_or_iana_to_iana(tz)
|
||||
}
|
||||
|
||||
fn resolve_well_known_roles(ctx: &GraphCoordinator<'_>) -> HashMap<String, &'static str> {
|
||||
let mapping: &[(&str, &str)] = &[
|
||||
("inbox", "inbox"),
|
||||
("drafts", "drafts"),
|
||||
("sentitems", "sent"),
|
||||
("deleteditems", "trash"),
|
||||
("junkemail", "junk"),
|
||||
("archive", "archive"),
|
||||
];
|
||||
let mut out = HashMap::new();
|
||||
for (short_name, role) in mapping {
|
||||
let url = ctx.endpoints.well_known_folder(short_name);
|
||||
match ctx.client.get_json(&url) {
|
||||
Ok(value) => {
|
||||
if let Some(id) = value.get("id").and_then(Value::as_str) {
|
||||
out.insert(id.to_owned(), *role);
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn order_by_parent(server: Vec<Value>) -> Vec<Value> {
|
||||
let mut parents: HashMap<String, Option<String>> = HashMap::new();
|
||||
for v in &server {
|
||||
if let Some(id) = v.get("id").and_then(Value::as_str) {
|
||||
let parent = v
|
||||
.get("parentFolderId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
parents.insert(id.to_owned(), parent);
|
||||
}
|
||||
}
|
||||
fn depth_of(
|
||||
id: &str,
|
||||
parents: &HashMap<String, Option<String>>,
|
||||
memo: &mut HashMap<String, usize>,
|
||||
seen: &mut std::collections::HashSet<String>,
|
||||
) -> usize {
|
||||
if let Some(d) = memo.get(id) {
|
||||
return *d;
|
||||
}
|
||||
if !seen.insert(id.to_owned()) {
|
||||
return 0;
|
||||
}
|
||||
let parent = parents.get(id).and_then(|p| p.as_deref());
|
||||
let d = match parent {
|
||||
Some(p) if parents.contains_key(p) => 1 + depth_of(p, parents, memo, seen),
|
||||
_ => 0,
|
||||
};
|
||||
memo.insert(id.to_owned(), d);
|
||||
d
|
||||
}
|
||||
let mut memo: HashMap<String, usize> = HashMap::new();
|
||||
let mut entries: Vec<(usize, Value)> = server
|
||||
.into_iter()
|
||||
.map(|v| {
|
||||
let id = v.get("id").and_then(Value::as_str).unwrap_or("").to_owned();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let d = depth_of(&id, &parents, &mut memo, &mut seen);
|
||||
(d, v)
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by_key(|(d, _)| *d);
|
||||
entries.into_iter().map(|(_, v)| v).collect()
|
||||
}
|
||||
|
||||
fn delete_vanished_flat(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
type_name: &str,
|
||||
table: &str,
|
||||
local: &HashMap<String, i64>,
|
||||
server_ids: &[String],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let server_set: std::collections::HashSet<&str> =
|
||||
server_ids.iter().map(String::as_str).collect();
|
||||
let vanished: Vec<(&String, &i64)> = local
|
||||
.iter()
|
||||
.filter(|(graph_id, _)| !server_set.contains(graph_id.as_str()))
|
||||
.collect();
|
||||
for chunk in vanished.chunks(CHUNK_SIZE) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for (graph_id, local_id) in chunk {
|
||||
let result = tx.execute(
|
||||
&format!("DELETE FROM {table} WHERE id = ?1"),
|
||||
params![local_id],
|
||||
);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
exchange_graph_ids::delete(&tx, source_id, type_name, graph_id)?;
|
||||
counts.deleted += 1;
|
||||
}
|
||||
Err(_) => {
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_vanished_mailboxes(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
local: &HashMap<String, i64>,
|
||||
server_ids: &[String],
|
||||
counts: &mut TypeCounts,
|
||||
logger: &crate::logging::Logger,
|
||||
) -> Result<(), Error> {
|
||||
let server_set: std::collections::HashSet<&str> =
|
||||
server_ids.iter().map(String::as_str).collect();
|
||||
let mut vanished: Vec<(String, i64)> = local
|
||||
.iter()
|
||||
.filter(|(id, _)| !server_set.contains(id.as_str()))
|
||||
.map(|(id, lid)| (id.clone(), *lid))
|
||||
.collect();
|
||||
let depths = mailbox_depths(conn, &vanished)?;
|
||||
vanished.sort_by_key(|(_, local_id)| std::cmp::Reverse(*depths.get(local_id).unwrap_or(&0)));
|
||||
|
||||
for (graph_id, local_id) in vanished {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let result = tx.execute("DELETE FROM mailboxes WHERE id = ?1", params![local_id]);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
exchange_graph_ids::delete(&tx, source_id, exchange_graph_ids::MAILBOX, &graph_id)?;
|
||||
tx.commit()?;
|
||||
counts.deleted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
logger.warn(&format!(
|
||||
"mailbox {graph_id} (local id {local_id}) could not be deleted (live children?): {e}"
|
||||
));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mailbox_depths(conn: &Connection, rows: &[(String, i64)]) -> Result<HashMap<i64, usize>, Error> {
|
||||
let mut parents: HashMap<i64, Option<i64>> = HashMap::new();
|
||||
{
|
||||
let mut stmt = conn.prepare("SELECT id, parent_id FROM mailboxes")?;
|
||||
let mut iter = stmt.query([])?;
|
||||
while let Some(row) = iter.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let parent: Option<i64> = row.get(1)?;
|
||||
parents.insert(id, parent);
|
||||
}
|
||||
}
|
||||
let mut depths: HashMap<i64, usize> = HashMap::new();
|
||||
for (_, local_id) in rows {
|
||||
depths.insert(*local_id, depth_of(*local_id, &parents));
|
||||
}
|
||||
Ok(depths)
|
||||
}
|
||||
|
||||
fn depth_of(id: i64, parents: &HashMap<i64, Option<i64>>) -> usize {
|
||||
let mut depth = 0;
|
||||
let mut cursor = id;
|
||||
let mut seen: std::collections::HashSet<i64> = std::collections::HashSet::new();
|
||||
while seen.insert(cursor) {
|
||||
match parents.get(&cursor).copied().flatten() {
|
||||
Some(p) => {
|
||||
depth += 1;
|
||||
cursor = p;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
depth
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn order_by_parent_returns_parents_first() {
|
||||
let input = vec![
|
||||
json!({"id": "C", "parentFolderId": "B"}),
|
||||
json!({"id": "B", "parentFolderId": "A"}),
|
||||
json!({"id": "A"}),
|
||||
];
|
||||
let out = order_by_parent(input);
|
||||
assert_eq!(out[0]["id"], "A");
|
||||
assert_eq!(out[1]["id"], "B");
|
||||
assert_eq!(out[2]["id"], "C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_by_parent_tolerates_missing_parent() {
|
||||
let input = vec![
|
||||
json!({"id": "Orphan", "parentFolderId": "Missing"}),
|
||||
json!({"id": "A"}),
|
||||
];
|
||||
let out = order_by_parent(input);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use rusqlite::{Connection, Transaction, params};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::db::{blobs, exchange_graph_ids};
|
||||
use crate::error::Error;
|
||||
use crate::exchange_graph::api;
|
||||
use crate::exchange_graph::client::Accept;
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
use crate::logging::LEVEL_PROGRESS;
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::emailmeta::email_meta_from_blob;
|
||||
use crate::sync::import_jmap::pool::Pool;
|
||||
use crate::sync::keys::index_to_json;
|
||||
|
||||
use super::coordinator::{CHUNK_SIZE, GraphCoordinator};
|
||||
use super::folders::MailFolder;
|
||||
|
||||
pub fn reconcile_all(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
folders: &[MailFolder],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let local: std::collections::HashMap<String, i64> =
|
||||
exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::EMAIL)?;
|
||||
let mut server_total: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut any_failure = false;
|
||||
|
||||
for folder in folders {
|
||||
let url = ctx.endpoints.folder_messages_ids(&folder.graph_id, ctx.top);
|
||||
let ids = match api::collect_all_ids(ctx.client, &url, &[]) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
ctx.logger.warn(&format!(
|
||||
"folder {} message enumeration failed: {e}",
|
||||
folder.graph_id
|
||||
));
|
||||
counts.failed += 1;
|
||||
any_failure = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if ctx.logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"graph folder {} enumerated {} messages",
|
||||
folder.graph_id,
|
||||
ids.len()
|
||||
);
|
||||
}
|
||||
for id in &ids {
|
||||
server_total.insert(id.clone());
|
||||
}
|
||||
let new_ids: Vec<String> = ids
|
||||
.into_iter()
|
||||
.filter(|id| !local.contains_key(id))
|
||||
.collect();
|
||||
if new_ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
fetch_and_insert(conn, ctx, folder, &new_ids, counts)?;
|
||||
}
|
||||
|
||||
if any_failure {
|
||||
ctx.logger.warn(
|
||||
"graph message vanished-cleanup skipped: one or more folders failed to enumerate; \
|
||||
a clean re-run will reconcile deletions",
|
||||
);
|
||||
} else {
|
||||
delete_vanished(conn, ctx.source_id, &local, &server_total, counts)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_and_insert(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
folder: &MailFolder,
|
||||
ids: &[String],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let client = ctx.client.clone();
|
||||
let endpoints: crate::exchange_graph::api::Endpoints = (*ctx.endpoints).clone();
|
||||
type FetchResult = (String, Result<Vec<u8>, GraphError>);
|
||||
let pool: Pool<String, FetchResult> = Pool::new(ctx.workers, move |id: String| {
|
||||
let url = endpoints.message_mime(&id);
|
||||
match client.get_with_prefer(&url, Accept::Text, &[]) {
|
||||
Ok(resp) => (id, Ok(resp.body)),
|
||||
Err(e) => (id, Err(e)),
|
||||
}
|
||||
});
|
||||
for id in ids {
|
||||
pool.submit(id.clone());
|
||||
}
|
||||
let mut tx_opt: Option<Transaction<'_>> = None;
|
||||
let mut in_batch: usize = 0;
|
||||
for _ in 0..ids.len() {
|
||||
let Ok((graph_id, result)) = pool.results().recv() else {
|
||||
break;
|
||||
};
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
if tx_opt.is_none() {
|
||||
tx_opt = Some(conn.unchecked_transaction()?);
|
||||
}
|
||||
let tx = tx_opt.as_mut().expect("tx is Some");
|
||||
apply_message_in_tx(tx, ctx, folder, &graph_id, &bytes, counts)?;
|
||||
in_batch += 1;
|
||||
if in_batch >= CHUNK_SIZE {
|
||||
if let Some(t) = tx_opt.take() {
|
||||
t.commit()?;
|
||||
}
|
||||
in_batch = 0;
|
||||
}
|
||||
}
|
||||
Err(GraphError::Vanished) => {
|
||||
counts.skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
counts.failed += 1;
|
||||
ctx.logger
|
||||
.warn(&format!("graph message {graph_id} fetch failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = tx_opt.take() {
|
||||
t.commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_message_in_tx(
|
||||
tx: &Transaction<'_>,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
folder: &MailFolder,
|
||||
graph_id: &str,
|
||||
bytes: &[u8],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let (idx, date_header) = email_meta_from_blob(bytes);
|
||||
let message_match = index_to_json(&idx);
|
||||
let received_at = date_header.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned());
|
||||
let mailbox_ids = json!([folder.local_id]).to_string();
|
||||
let keywords = "[]".to_owned();
|
||||
|
||||
let blob_id = blobs::intern_blob(tx, bytes)?;
|
||||
tx.execute(
|
||||
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords, message_match)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![blob_id, received_at, mailbox_ids, keywords, message_match],
|
||||
)?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_graph_ids::insert(
|
||||
tx,
|
||||
ctx.source_id,
|
||||
exchange_graph_ids::EMAIL,
|
||||
graph_id,
|
||||
new_id,
|
||||
)?;
|
||||
counts.created += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_vanished(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
local: &std::collections::HashMap<String, i64>,
|
||||
server: &std::collections::HashSet<String>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let vanished: Vec<(&String, &i64)> = local
|
||||
.iter()
|
||||
.filter(|(graph_id, _)| !server.contains(graph_id.as_str()))
|
||||
.collect();
|
||||
for chunk in vanished.chunks(CHUNK_SIZE) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for (graph_id, local_id) in chunk {
|
||||
let result = tx.execute("DELETE FROM emails WHERE id = ?1", params![local_id]);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
exchange_graph_ids::delete(
|
||||
&tx,
|
||||
source_id,
|
||||
exchange_graph_ids::EMAIL,
|
||||
graph_id,
|
||||
)?;
|
||||
counts.deleted += 1;
|
||||
}
|
||||
Err(_) => {
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod fetch;
|
||||
pub mod folders;
|
||||
pub mod internaldate;
|
||||
pub mod keywords;
|
||||
pub mod messages;
|
||||
pub mod pool;
|
||||
|
||||
pub mod coordinator;
|
||||
|
||||
pub use coordinator::{ImapAuth, ImapImportConfig, run};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use crate::imap::response::{Untagged, Value};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct FetchAttrs {
|
||||
pub uid: Option<u32>,
|
||||
pub flags: Vec<String>,
|
||||
pub internaldate: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
pub body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub fn extract(u: &Untagged) -> Option<FetchAttrs> {
|
||||
let Untagged::Fetch { items, .. } = u else {
|
||||
return None;
|
||||
};
|
||||
let mut out = FetchAttrs::default();
|
||||
for (name, value) in items {
|
||||
match name.as_str() {
|
||||
"UID" => {
|
||||
if let Some(n) = value.as_number() {
|
||||
out.uid = Some(n as u32);
|
||||
}
|
||||
}
|
||||
"FLAGS" => {
|
||||
if let Value::List(items) = value {
|
||||
out.flags = items
|
||||
.iter()
|
||||
.filter_map(|v| match v {
|
||||
Value::Atom(s) | Value::Str(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
"INTERNALDATE" => {
|
||||
if let Some(s) = value.as_str() {
|
||||
out.internaldate = Some(s.to_owned());
|
||||
}
|
||||
}
|
||||
"RFC822.SIZE" => {
|
||||
if let Some(n) = value.as_number() {
|
||||
out.size = Some(n);
|
||||
}
|
||||
}
|
||||
n if n == "BODY[]" || n == "RFC822" => {
|
||||
out.body = match value {
|
||||
Value::Bytes(b) => Some(b.clone()),
|
||||
Value::Str(s) => Some(s.clone().into_bytes()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
fn parse_fetch(input: &[u8]) -> FetchAttrs {
|
||||
let resp = crate::imap::response::parse_response(&mut Cursor::new(input)).unwrap();
|
||||
match resp {
|
||||
crate::imap::response::Response::Untagged(u) => extract(&u).expect("fetch attrs"),
|
||||
_ => panic!("expected untagged"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_uid_flags_size_internaldate() {
|
||||
let f = parse_fetch(
|
||||
b"* 1 FETCH (UID 42 FLAGS (\\Seen) INTERNALDATE \"01-Jan-2024 12:00:00 +0000\" RFC822.SIZE 4242)\r\n",
|
||||
);
|
||||
assert_eq!(f.uid, Some(42));
|
||||
assert_eq!(f.flags, vec!["\\Seen"]);
|
||||
assert_eq!(
|
||||
f.internaldate.as_deref(),
|
||||
Some("01-Jan-2024 12:00:00 +0000")
|
||||
);
|
||||
assert_eq!(f.size, Some(4242));
|
||||
assert!(f.body.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_body_literal_as_bytes() {
|
||||
let input = b"* 1 FETCH (UID 5 BODY[] {11}\r\nHello world)\r\n";
|
||||
let f = parse_fetch(input);
|
||||
assert_eq!(f.body.as_deref(), Some(&b"Hello world"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_attrs() {
|
||||
let f = parse_fetch(b"* 1 FETCH (UID 1 X-CUSTOM \"ignored\" MODSEQ (12345))\r\n");
|
||||
assert_eq!(f.uid, Some(1));
|
||||
assert!(f.flags.is_empty());
|
||||
assert!(f.size.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_flags_list() {
|
||||
let f = parse_fetch(b"* 1 FETCH (UID 7 FLAGS ())\r\n");
|
||||
assert_eq!(f.uid, Some(7));
|
||||
assert!(f.flags.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::imap::automap;
|
||||
use crate::imap::name::{canonicalise_inbox, decode_mailbox_name_with};
|
||||
use crate::imap::response::Untagged;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FolderStatus {
|
||||
pub uidvalidity: Option<u64>,
|
||||
pub uidnext: Option<u64>,
|
||||
pub messages: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredFolder {
|
||||
pub name: String,
|
||||
pub delimiter: Option<char>,
|
||||
pub attributes: Vec<String>,
|
||||
pub subscribed: bool,
|
||||
pub selectable: bool,
|
||||
pub status: Option<FolderStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedFolder {
|
||||
pub name: String,
|
||||
pub leaf: String,
|
||||
pub parent_path: Option<String>,
|
||||
pub delimiter: Option<char>,
|
||||
pub role: Option<&'static str>,
|
||||
pub subscribed: bool,
|
||||
pub status: Option<FolderStatus>,
|
||||
}
|
||||
|
||||
pub struct FolderFilters {
|
||||
pub include: Vec<Regex>,
|
||||
pub exclude: Vec<Regex>,
|
||||
pub exclude_special: Vec<String>,
|
||||
pub explicit: Vec<String>,
|
||||
pub subscribed_only: bool,
|
||||
pub automap_enabled: bool,
|
||||
pub namespace_prefix: String,
|
||||
}
|
||||
|
||||
pub fn collect_from_list(
|
||||
untagged: &[Untagged],
|
||||
utf8_accept: bool,
|
||||
) -> Result<Vec<DiscoveredFolder>, String> {
|
||||
let mut subscribed = HashSet::new();
|
||||
let mut statuses: BTreeMap<String, FolderStatus> = BTreeMap::new();
|
||||
let mut found = BTreeMap::new();
|
||||
for u in untagged {
|
||||
match u {
|
||||
Untagged::Lsub { name, .. } => {
|
||||
let decoded =
|
||||
decode_mailbox_name_with(name, utf8_accept).map_err(|e| e.to_string())?;
|
||||
subscribed.insert(canonicalise_inbox(&decoded));
|
||||
}
|
||||
Untagged::List {
|
||||
attributes,
|
||||
delimiter,
|
||||
name,
|
||||
} => {
|
||||
let decoded =
|
||||
decode_mailbox_name_with(name, utf8_accept).map_err(|e| e.to_string())?;
|
||||
let canonical = canonicalise_inbox(&decoded);
|
||||
let attrs_lower: Vec<String> =
|
||||
attributes.iter().map(|a| a.to_ascii_lowercase()).collect();
|
||||
let selectable = !attrs_lower
|
||||
.iter()
|
||||
.any(|a| a == "\\noselect" || a == "\\nonexistent");
|
||||
let extended_subscribed = attrs_lower.iter().any(|a| a == "\\subscribed");
|
||||
if extended_subscribed {
|
||||
subscribed.insert(canonical.clone());
|
||||
}
|
||||
found.insert(
|
||||
canonical.clone(),
|
||||
DiscoveredFolder {
|
||||
name: canonical,
|
||||
delimiter: *delimiter,
|
||||
attributes: attributes.clone(),
|
||||
subscribed: false,
|
||||
selectable,
|
||||
status: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Untagged::Status { mailbox, items } => {
|
||||
let decoded =
|
||||
decode_mailbox_name_with(mailbox, utf8_accept).map_err(|e| e.to_string())?;
|
||||
let canonical = canonicalise_inbox(&decoded);
|
||||
let mut st = FolderStatus::default();
|
||||
if let Some(v) = items.get("UIDVALIDITY") {
|
||||
st.uidvalidity = Some(*v);
|
||||
}
|
||||
if let Some(v) = items.get("UIDNEXT") {
|
||||
st.uidnext = Some(*v);
|
||||
}
|
||||
if let Some(v) = items.get("MESSAGES") {
|
||||
st.messages = Some(*v);
|
||||
}
|
||||
statuses.insert(canonical, st);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<DiscoveredFolder> = found.into_values().collect();
|
||||
for f in &mut out {
|
||||
if subscribed.contains(&f.name) {
|
||||
f.subscribed = true;
|
||||
}
|
||||
if let Some(s) = statuses.remove(&f.name) {
|
||||
f.status = Some(s);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn apply_filters(
|
||||
folders: Vec<DiscoveredFolder>,
|
||||
filters: &FolderFilters,
|
||||
) -> Vec<ResolvedFolder> {
|
||||
let mut keep: Vec<DiscoveredFolder> = folders
|
||||
.into_iter()
|
||||
.filter(|f| f.selectable)
|
||||
.filter(|f| match_filters(&f.name, filters))
|
||||
.collect();
|
||||
if filters.subscribed_only {
|
||||
keep.retain(|f| f.subscribed);
|
||||
}
|
||||
let mut resolved: Vec<ResolvedFolder> = Vec::with_capacity(keep.len());
|
||||
for f in keep {
|
||||
let role = automap::role_for_folder(
|
||||
&f.name,
|
||||
&f.attributes,
|
||||
&filters.namespace_prefix,
|
||||
filters.automap_enabled,
|
||||
);
|
||||
if let Some(r) = role
|
||||
&& filters
|
||||
.exclude_special
|
||||
.iter()
|
||||
.any(|s| s.eq_ignore_ascii_case(r))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let delim = f.delimiter;
|
||||
let (leaf, parent_path) = split_parent(&f.name, delim);
|
||||
resolved.push(ResolvedFolder {
|
||||
name: f.name,
|
||||
leaf,
|
||||
parent_path,
|
||||
delimiter: delim,
|
||||
role,
|
||||
subscribed: f.subscribed,
|
||||
status: f.status,
|
||||
});
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
fn match_filters(name: &str, filters: &FolderFilters) -> bool {
|
||||
if !filters.explicit.is_empty() {
|
||||
return filters.explicit.iter().any(|e| e == name);
|
||||
}
|
||||
if !filters.include.is_empty() && !filters.include.iter().any(|r| r.is_match(name)) {
|
||||
return false;
|
||||
}
|
||||
if filters.exclude.iter().any(|r| r.is_match(name)) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn split_parent(name: &str, delim: Option<char>) -> (String, Option<String>) {
|
||||
let Some(d) = delim else {
|
||||
return (name.to_owned(), None);
|
||||
};
|
||||
if let Some(idx) = name.rfind(d) {
|
||||
let parent = &name[..idx];
|
||||
let leaf = &name[idx + d.len_utf8()..];
|
||||
(leaf.to_owned(), Some(parent.to_owned()))
|
||||
} else {
|
||||
(name.to_owned(), None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_by_depth(resolved: &mut [ResolvedFolder]) {
|
||||
resolved.sort_by(|a, b| {
|
||||
let da = a.parent_path.as_deref().map_or(0, |s| {
|
||||
a.delimiter.map(|d| s.matches(d).count() + 1).unwrap_or(0)
|
||||
});
|
||||
let db = b.parent_path.as_deref().map_or(0, |s| {
|
||||
b.delimiter.map(|d| s.matches(d).count() + 1).unwrap_or(0)
|
||||
});
|
||||
da.cmp(&db).then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
}
|
||||
|
||||
pub fn vanished_folders(local: &HashMap<String, i64>, server: &HashSet<String>) -> Vec<String> {
|
||||
local
|
||||
.keys()
|
||||
.filter(|k| !server.contains(*k))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn vanished_depth_sort(names: &mut [String], delimiter: char) {
|
||||
names.sort_by(|a, b| {
|
||||
let depth_a = a.matches(delimiter).count();
|
||||
let depth_b = b.matches(delimiter).count();
|
||||
depth_b.cmp(&depth_a).then_with(|| a.cmp(b))
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lst(name: &str, delim: &str, attrs: &[&str]) -> Untagged {
|
||||
Untagged::List {
|
||||
attributes: attrs.iter().map(|s| (*s).to_owned()).collect(),
|
||||
delimiter: delim.chars().next(),
|
||||
name: name.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_filters_noselect_and_canonicalises_inbox() {
|
||||
let resps = vec![
|
||||
lst("Inbox", "/", &[]),
|
||||
lst("Sent", "/", &["\\Sent"]),
|
||||
lst("Hidden", "/", &["\\Noselect"]),
|
||||
];
|
||||
let folders = collect_from_list(&resps, false).unwrap();
|
||||
assert_eq!(folders.len(), 3);
|
||||
let inbox = folders.iter().find(|f| f.name == "INBOX").unwrap();
|
||||
assert!(inbox.selectable);
|
||||
let hidden = folders.iter().find(|f| f.name == "Hidden").unwrap();
|
||||
assert!(!hidden.selectable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_marks_subscribed_via_lsub_or_extended() {
|
||||
let resps = vec![
|
||||
lst("Inbox", "/", &[]),
|
||||
lst("Sent", "/", &["\\Subscribed"]),
|
||||
Untagged::Lsub {
|
||||
attributes: vec![],
|
||||
delimiter: Some('/'),
|
||||
name: "Inbox".to_owned(),
|
||||
},
|
||||
];
|
||||
let folders = collect_from_list(&resps, false).unwrap();
|
||||
let inbox = folders.iter().find(|f| f.name == "INBOX").unwrap();
|
||||
assert!(inbox.subscribed);
|
||||
let sent = folders.iter().find(|f| f.name == "Sent").unwrap();
|
||||
assert!(sent.subscribed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_attaches_list_status_data_per_mailbox() {
|
||||
let mut inbox_status: std::collections::BTreeMap<String, u64> =
|
||||
std::collections::BTreeMap::new();
|
||||
inbox_status.insert("UIDVALIDITY".into(), 12345);
|
||||
inbox_status.insert("UIDNEXT".into(), 42);
|
||||
inbox_status.insert("MESSAGES".into(), 7);
|
||||
let resps = vec![
|
||||
lst("INBOX", "/", &[]),
|
||||
lst("Sent", "/", &["\\Sent"]),
|
||||
Untagged::Status {
|
||||
mailbox: "INBOX".to_owned(),
|
||||
items: inbox_status,
|
||||
},
|
||||
];
|
||||
let folders = collect_from_list(&resps, false).unwrap();
|
||||
let inbox = folders.iter().find(|f| f.name == "INBOX").unwrap();
|
||||
let st = inbox.status.as_ref().expect("INBOX has status");
|
||||
assert_eq!(st.uidvalidity, Some(12345));
|
||||
assert_eq!(st.uidnext, Some(42));
|
||||
assert_eq!(st.messages, Some(7));
|
||||
|
||||
let sent = folders.iter().find(|f| f.name == "Sent").unwrap();
|
||||
assert!(sent.status.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_decodes_modified_utf7_names() {
|
||||
let resps = vec![lst("&ZeVnLIqe-", "/", &[])];
|
||||
let folders = collect_from_list(&resps, false).unwrap();
|
||||
assert_eq!(folders[0].name, "日本語");
|
||||
}
|
||||
|
||||
fn filters_default() -> FolderFilters {
|
||||
FolderFilters {
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
exclude_special: Vec::new(),
|
||||
explicit: Vec::new(),
|
||||
subscribed_only: false,
|
||||
automap_enabled: true,
|
||||
namespace_prefix: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_default_keep_everything() {
|
||||
let folders = collect_from_list(
|
||||
&[
|
||||
lst("INBOX", "/", &[]),
|
||||
lst("Sent", "/", &["\\Sent"]),
|
||||
lst("Drafts", "/", &[]),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let res = apply_filters(folders, &filters_default());
|
||||
assert_eq!(res.len(), 3);
|
||||
let sent = res.iter().find(|f| f.name == "Sent").unwrap();
|
||||
assert_eq!(sent.role, Some("sent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_excludes_non_matching() {
|
||||
let folders = collect_from_list(
|
||||
&[
|
||||
lst("INBOX", "/", &[]),
|
||||
lst("Trash", "/", &[]),
|
||||
lst("Sent", "/", &["\\Sent"]),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let mut f = filters_default();
|
||||
f.include = vec![Regex::new(r"^(INBOX|Sent)$").unwrap()];
|
||||
let res = apply_filters(folders, &f);
|
||||
assert_eq!(res.len(), 2);
|
||||
assert!(res.iter().any(|x| x.name == "INBOX"));
|
||||
assert!(res.iter().any(|x| x.name == "Sent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_drops_matching() {
|
||||
let folders =
|
||||
collect_from_list(&[lst("INBOX", "/", &[]), lst("Trash", "/", &[])], false).unwrap();
|
||||
let mut f = filters_default();
|
||||
f.exclude = vec![Regex::new(r"^Trash$").unwrap()];
|
||||
let res = apply_filters(folders, &f);
|
||||
assert_eq!(res.len(), 1);
|
||||
assert_eq!(res[0].name, "INBOX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_special_drops_by_role() {
|
||||
let folders = collect_from_list(
|
||||
&[lst("INBOX", "/", &[]), lst("Trash", "/", &["\\Trash"])],
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let mut f = filters_default();
|
||||
f.exclude_special = vec!["trash".to_owned()];
|
||||
let res = apply_filters(folders, &f);
|
||||
assert_eq!(res.len(), 1);
|
||||
assert_eq!(res[0].name, "INBOX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_folder_list_overrides_include_exclude() {
|
||||
let folders = collect_from_list(
|
||||
&[
|
||||
lst("INBOX", "/", &[]),
|
||||
lst("Sent", "/", &[]),
|
||||
lst("Trash", "/", &[]),
|
||||
],
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let mut f = filters_default();
|
||||
f.explicit = vec!["Sent".to_owned()];
|
||||
let res = apply_filters(folders, &f);
|
||||
assert_eq!(res.len(), 1);
|
||||
assert_eq!(res[0].name, "Sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribed_only_filters_unsubscribed() {
|
||||
let mut folders = collect_from_list(
|
||||
&[lst("INBOX", "/", &["\\Subscribed"]), lst("Other", "/", &[])],
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut f = filters_default();
|
||||
f.subscribed_only = true;
|
||||
|
||||
for x in &mut folders {
|
||||
if x.name == "INBOX" {
|
||||
x.subscribed = true;
|
||||
}
|
||||
}
|
||||
let res = apply_filters(folders, &f);
|
||||
assert_eq!(res.len(), 1);
|
||||
assert_eq!(res[0].name, "INBOX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_parent_handles_root_and_nested() {
|
||||
assert_eq!(split_parent("INBOX", Some('/')), ("INBOX".to_owned(), None));
|
||||
assert_eq!(
|
||||
split_parent("Projects/Alpha", Some('/')),
|
||||
("Alpha".to_owned(), Some("Projects".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
split_parent("Projects/Alpha/Beta", Some('/')),
|
||||
("Beta".to_owned(), Some("Projects/Alpha".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_parent_with_no_delimiter_returns_root() {
|
||||
assert_eq!(
|
||||
split_parent("Anything", None),
|
||||
("Anything".to_owned(), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_by_depth_places_roots_first() {
|
||||
let mut r = vec![
|
||||
ResolvedFolder {
|
||||
name: "Projects/Alpha".into(),
|
||||
leaf: "Alpha".into(),
|
||||
parent_path: Some("Projects".into()),
|
||||
delimiter: Some('/'),
|
||||
role: None,
|
||||
subscribed: false,
|
||||
status: None,
|
||||
},
|
||||
ResolvedFolder {
|
||||
name: "INBOX".into(),
|
||||
leaf: "INBOX".into(),
|
||||
parent_path: None,
|
||||
delimiter: Some('/'),
|
||||
role: Some("inbox"),
|
||||
subscribed: true,
|
||||
status: None,
|
||||
},
|
||||
ResolvedFolder {
|
||||
name: "Projects".into(),
|
||||
leaf: "Projects".into(),
|
||||
parent_path: None,
|
||||
delimiter: Some('/'),
|
||||
role: None,
|
||||
subscribed: false,
|
||||
status: None,
|
||||
},
|
||||
];
|
||||
sort_by_depth(&mut r);
|
||||
assert_eq!(r[0].name, "INBOX");
|
||||
assert_eq!(r[1].name, "Projects");
|
||||
assert_eq!(r[2].name, "Projects/Alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanished_folders_is_set_minus() {
|
||||
let mut local = HashMap::new();
|
||||
local.insert("INBOX".to_owned(), 1);
|
||||
local.insert("Old".to_owned(), 2);
|
||||
local.insert("Sent".to_owned(), 3);
|
||||
let mut server = HashSet::new();
|
||||
server.insert("INBOX".to_owned());
|
||||
server.insert("Sent".to_owned());
|
||||
let v = vanished_folders(&local, &server);
|
||||
assert_eq!(v, vec!["Old".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanished_depth_sort_deepest_first() {
|
||||
let mut names = vec!["A".to_owned(), "A/B/C".to_owned(), "A/B".to_owned()];
|
||||
vanished_depth_sort(&mut names, '/');
|
||||
assert_eq!(names, vec!["A/B/C", "A/B", "A"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untagged_status_does_not_become_a_folder() {
|
||||
let resps = vec![
|
||||
lst("INBOX", "/", &[]),
|
||||
Untagged::Status {
|
||||
mailbox: "INBOX".to_owned(),
|
||||
items: BTreeMap::new(),
|
||||
},
|
||||
];
|
||||
let folders = collect_from_list(&resps, false).unwrap();
|
||||
assert_eq!(folders.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
pub fn imap_internaldate_to_rfc3339(s: &str) -> Result<String, Error> {
|
||||
let s = s.trim_start_matches(' ').trim_end();
|
||||
let (day, rest) = s
|
||||
.split_once('-')
|
||||
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: missing day in {s:?}")))?;
|
||||
let (mon, rest) = rest
|
||||
.split_once('-')
|
||||
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: missing month in {s:?}")))?;
|
||||
let (year, rest) = rest
|
||||
.split_once(' ')
|
||||
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: missing year in {s:?}")))?;
|
||||
let (time, zone) = rest
|
||||
.split_once(' ')
|
||||
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: missing zone in {s:?}")))?;
|
||||
let (h, ms) = time
|
||||
.split_once(':')
|
||||
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: bad time {time:?}")))?;
|
||||
let (m, sec) = ms
|
||||
.split_once(':')
|
||||
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: bad time {time:?}")))?;
|
||||
let day: u32 = day
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|e| Error::Partial(format!("INTERNALDATE day {day:?}: {e}")))?;
|
||||
let mon_idx = month_to_num(mon)?;
|
||||
let year: i32 = year
|
||||
.parse()
|
||||
.map_err(|e| Error::Partial(format!("INTERNALDATE year {year:?}: {e}")))?;
|
||||
let h: u32 = h
|
||||
.parse()
|
||||
.map_err(|e| Error::Partial(format!("INTERNALDATE hour {h:?}: {e}")))?;
|
||||
let m: u32 = m
|
||||
.parse()
|
||||
.map_err(|e| Error::Partial(format!("INTERNALDATE minute {m:?}: {e}")))?;
|
||||
let sec: u32 = sec
|
||||
.parse()
|
||||
.map_err(|e| Error::Partial(format!("INTERNALDATE second {sec:?}: {e}")))?;
|
||||
let (sign, hh, mm) = parse_zone(zone)?;
|
||||
let mut out = String::with_capacity(32);
|
||||
use std::fmt::Write;
|
||||
let _ = write!(
|
||||
&mut out,
|
||||
"{year:04}-{mon_idx:02}-{day:02}T{h:02}:{m:02}:{sec:02}"
|
||||
);
|
||||
if hh == 0 && mm == 0 {
|
||||
out.push('Z');
|
||||
} else {
|
||||
let _ = write!(&mut out, "{sign}{hh:02}:{mm:02}");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn month_to_num(s: &str) -> Result<u32, Error> {
|
||||
let m = match s {
|
||||
"Jan" => 1,
|
||||
"Feb" => 2,
|
||||
"Mar" => 3,
|
||||
"Apr" => 4,
|
||||
"May" => 5,
|
||||
"Jun" => 6,
|
||||
"Jul" => 7,
|
||||
"Aug" => 8,
|
||||
"Sep" => 9,
|
||||
"Oct" => 10,
|
||||
"Nov" => 11,
|
||||
"Dec" => 12,
|
||||
other => return Err(Error::Partial(format!("INTERNALDATE month {other:?}"))),
|
||||
};
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn parse_zone(s: &str) -> Result<(char, u32, u32), Error> {
|
||||
let s = s.trim();
|
||||
if s.len() != 5 {
|
||||
return Err(Error::Partial(format!("INTERNALDATE zone {s:?}")));
|
||||
}
|
||||
let sign = s.as_bytes()[0] as char;
|
||||
if sign != '+' && sign != '-' {
|
||||
return Err(Error::Partial(format!("INTERNALDATE zone sign {sign}")));
|
||||
}
|
||||
let hh: u32 = s[1..3]
|
||||
.parse()
|
||||
.map_err(|e| Error::Partial(format!("INTERNALDATE zone hour: {e}")))?;
|
||||
let mm: u32 = s[3..5]
|
||||
.parse()
|
||||
.map_err(|e| Error::Partial(format!("INTERNALDATE zone min: {e}")))?;
|
||||
Ok((sign, hh, mm))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn utc_zone_becomes_z() {
|
||||
assert_eq!(
|
||||
imap_internaldate_to_rfc3339("17-Jul-1996 02:44:25 +0000").unwrap(),
|
||||
"1996-07-17T02:44:25Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_offset_preserved() {
|
||||
assert_eq!(
|
||||
imap_internaldate_to_rfc3339("17-Jul-1996 02:44:25 +0200").unwrap(),
|
||||
"1996-07-17T02:44:25+02:00"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_offset_preserved() {
|
||||
assert_eq!(
|
||||
imap_internaldate_to_rfc3339("17-Jul-1996 02:44:25 -0700").unwrap(),
|
||||
"1996-07-17T02:44:25-07:00"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_digit_day_padded() {
|
||||
assert_eq!(
|
||||
imap_internaldate_to_rfc3339("5-Jan-2024 09:05:01 +0000").unwrap(),
|
||||
"2024-01-05T09:05:01Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_space_single_digit_day() {
|
||||
assert_eq!(
|
||||
imap_internaldate_to_rfc3339(" 5-Jan-2024 09:05:01 +0000").unwrap(),
|
||||
"2024-01-05T09:05:01Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_month_errors() {
|
||||
assert!(imap_internaldate_to_rfc3339("1-XYZ-2024 00:00:00 +0000").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_input_errors() {
|
||||
assert!(imap_internaldate_to_rfc3339("garbage").is_err());
|
||||
assert!(imap_internaldate_to_rfc3339("17-Jul-1996").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FlagAction {
|
||||
Keep(&'static str),
|
||||
Drop,
|
||||
Verbatim,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
pub fn translate_system_flag(flag: &str) -> FlagAction {
|
||||
if !flag.starts_with('\\') {
|
||||
return FlagAction::Verbatim;
|
||||
}
|
||||
match &flag[1..].to_ascii_lowercase()[..] {
|
||||
"seen" => FlagAction::Keep("$seen"),
|
||||
"flagged" => FlagAction::Keep("$flagged"),
|
||||
"answered" => FlagAction::Keep("$answered"),
|
||||
"draft" => FlagAction::Keep("$draft"),
|
||||
"recent" => FlagAction::Drop,
|
||||
"deleted" => FlagAction::Deleted,
|
||||
_ => FlagAction::Verbatim,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Translation {
|
||||
pub keywords: Vec<String>,
|
||||
pub has_deleted_flag: bool,
|
||||
}
|
||||
|
||||
pub fn translate_flags(imap_flags: &[String], include_deleted: bool) -> Translation {
|
||||
let mut out: Vec<String> = Vec::with_capacity(imap_flags.len());
|
||||
let mut has_deleted = false;
|
||||
for f in imap_flags {
|
||||
if f.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match translate_system_flag(f) {
|
||||
FlagAction::Keep(k) => {
|
||||
if !out.iter().any(|x| x.eq_ignore_ascii_case(k)) {
|
||||
out.push(k.to_owned());
|
||||
}
|
||||
}
|
||||
FlagAction::Drop => {}
|
||||
FlagAction::Verbatim => {
|
||||
if !out.iter().any(|x| x.eq_ignore_ascii_case(f)) {
|
||||
out.push(f.clone());
|
||||
}
|
||||
}
|
||||
FlagAction::Deleted => {
|
||||
has_deleted = true;
|
||||
if include_deleted && !out.iter().any(|x| x.eq_ignore_ascii_case("$deleted")) {
|
||||
out.push("$deleted".to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Translation {
|
||||
keywords: out,
|
||||
has_deleted_flag: has_deleted,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn flags(s: &[&str]) -> Vec<String> {
|
||||
s.iter().map(|x| (*x).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_flag_mapping() {
|
||||
assert!(matches!(
|
||||
translate_system_flag("\\Seen"),
|
||||
FlagAction::Keep("$seen")
|
||||
));
|
||||
assert!(matches!(
|
||||
translate_system_flag("\\Flagged"),
|
||||
FlagAction::Keep("$flagged")
|
||||
));
|
||||
assert!(matches!(
|
||||
translate_system_flag("\\Answered"),
|
||||
FlagAction::Keep("$answered")
|
||||
));
|
||||
assert!(matches!(
|
||||
translate_system_flag("\\Draft"),
|
||||
FlagAction::Keep("$draft")
|
||||
));
|
||||
assert!(matches!(
|
||||
translate_system_flag("\\Recent"),
|
||||
FlagAction::Drop
|
||||
));
|
||||
assert!(matches!(
|
||||
translate_system_flag("\\Deleted"),
|
||||
FlagAction::Deleted
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_system_flag_passes_verbatim() {
|
||||
assert!(matches!(
|
||||
translate_system_flag("\\Forwarded"),
|
||||
FlagAction::Verbatim
|
||||
));
|
||||
assert!(matches!(
|
||||
translate_system_flag("\\MDNSent"),
|
||||
FlagAction::Verbatim
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_keywords_pass_verbatim_case() {
|
||||
let t = translate_flags(&flags(&["$Junk", "NotJunk", "MyLabel"]), false);
|
||||
assert_eq!(t.keywords, vec!["$Junk", "NotJunk", "MyLabel"]);
|
||||
assert!(!t.has_deleted_flag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translates_seen_and_flagged() {
|
||||
let t = translate_flags(&flags(&["\\Seen", "\\Flagged"]), false);
|
||||
assert_eq!(t.keywords, vec!["$seen", "$flagged"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_recent() {
|
||||
let t = translate_flags(&flags(&["\\Seen", "\\Recent"]), false);
|
||||
assert_eq!(t.keywords, vec!["$seen"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_flag_without_include_marks_only() {
|
||||
let t = translate_flags(&flags(&["\\Deleted", "\\Seen"]), false);
|
||||
assert!(t.has_deleted_flag);
|
||||
assert_eq!(t.keywords, vec!["$seen"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_flag_with_include_adds_dollar_deleted() {
|
||||
let t = translate_flags(&flags(&["\\Deleted", "\\Seen"]), true);
|
||||
assert!(t.has_deleted_flag);
|
||||
assert_eq!(t.keywords, vec!["$deleted", "$seen"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keywords_deduped_case_insensitively() {
|
||||
let t = translate_flags(&flags(&["mykey", "MYKEY", "MyKey"]), false);
|
||||
assert_eq!(t.keywords, vec!["mykey"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_flag_strings_ignored() {
|
||||
let t = translate_flags(&flags(&["", "\\Seen", ""]), false);
|
||||
assert_eq!(t.keywords, vec!["$seen"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_preserved() {
|
||||
let t = translate_flags(
|
||||
&flags(&["MyLabel", "\\Seen", "$Important", "\\Flagged"]),
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
t.keywords,
|
||||
vec!["MyLabel", "$seen", "$Important", "$flagged"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::imap::client::ImapClient;
|
||||
use crate::imap::command;
|
||||
use crate::imap::error::ImapError;
|
||||
use crate::imap::response::Untagged;
|
||||
use crate::imap::retry::is_negotiation_failure;
|
||||
|
||||
pub fn select_uids(client: &mut ImapClient) -> Result<Vec<u32>, ImapError> {
|
||||
if client.has_capability("ESEARCH") {
|
||||
match client.run_collect(command::uid_search_esearch_all()) {
|
||||
Ok(resp) => {
|
||||
for u in &resp.untagged {
|
||||
if let Untagged::Esearch { all, .. } = u {
|
||||
return Ok(all.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if is_negotiation_failure(&e) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
match client.run_collect(command::uid_search_all()) {
|
||||
Ok(resp) => {
|
||||
for u in &resp.untagged {
|
||||
if let Untagged::Search(uids) = u {
|
||||
return Ok(uids.clone());
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
Err(e) if is_negotiation_failure(&e) => uids_via_fetch(client),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn uids_via_fetch(client: &mut ImapClient) -> Result<Vec<u32>, ImapError> {
|
||||
let resp = client.run_collect(command::uid_fetch_all_uids())?;
|
||||
let mut out = Vec::new();
|
||||
for u in resp.untagged {
|
||||
if let Untagged::Fetch { items, .. } = u {
|
||||
for (n, v) in items {
|
||||
if n == "UID"
|
||||
&& let Some(num) = v.as_number()
|
||||
{
|
||||
out.push(num as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UidDiff {
|
||||
pub new: Vec<u32>,
|
||||
pub vanished: Vec<u32>,
|
||||
pub present: Vec<u32>,
|
||||
}
|
||||
|
||||
pub fn diff_uids(local: &[u32], server: &[u32]) -> UidDiff {
|
||||
let local_set: HashSet<u32> = local.iter().copied().collect();
|
||||
let server_set: HashSet<u32> = server.iter().copied().collect();
|
||||
let mut new = Vec::new();
|
||||
let mut present = Vec::new();
|
||||
for &uid in server {
|
||||
if local_set.contains(&uid) {
|
||||
present.push(uid);
|
||||
} else {
|
||||
new.push(uid);
|
||||
}
|
||||
}
|
||||
let mut vanished: Vec<u32> = local
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|u| !server_set.contains(u))
|
||||
.collect();
|
||||
new.sort_unstable();
|
||||
new.dedup();
|
||||
vanished.sort_unstable();
|
||||
vanished.dedup();
|
||||
present.sort_unstable();
|
||||
present.dedup();
|
||||
UidDiff {
|
||||
new,
|
||||
vanished,
|
||||
present,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chunks(uids: &[u32], chunk_size: usize) -> Vec<&[u32]> {
|
||||
if chunk_size == 0 {
|
||||
return vec![uids];
|
||||
}
|
||||
uids.chunks(chunk_size).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn diff_basic() {
|
||||
let local = vec![1, 2, 5];
|
||||
let server = vec![2, 3, 5, 7];
|
||||
let d = diff_uids(&local, &server);
|
||||
assert_eq!(d.new, vec![3, 7]);
|
||||
assert_eq!(d.vanished, vec![1]);
|
||||
assert_eq!(d.present, vec![2, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_empty_local_is_all_new() {
|
||||
let d = diff_uids(&[], &[1, 2, 3]);
|
||||
assert_eq!(d.new, vec![1, 2, 3]);
|
||||
assert!(d.vanished.is_empty());
|
||||
assert!(d.present.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_empty_server_is_all_vanished() {
|
||||
let d = diff_uids(&[1, 2, 3], &[]);
|
||||
assert_eq!(d.vanished, vec![1, 2, 3]);
|
||||
assert!(d.new.is_empty());
|
||||
assert!(d.present.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_dedups_inputs() {
|
||||
let d = diff_uids(&[1, 1, 2, 2], &[2, 2, 3, 3]);
|
||||
assert_eq!(d.new, vec![3]);
|
||||
assert_eq!(d.vanished, vec![1]);
|
||||
assert_eq!(d.present, vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_splits_evenly() {
|
||||
let v: Vec<u32> = (1..=10).collect();
|
||||
let c = chunks(&v, 3);
|
||||
assert_eq!(c.len(), 4);
|
||||
assert_eq!(c[0], &[1, 2, 3][..]);
|
||||
assert_eq!(c[3], &[10][..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_zero_returns_whole() {
|
||||
let v: Vec<u32> = vec![1, 2, 3];
|
||||
let c = chunks(&v, 0);
|
||||
assert_eq!(c.len(), 1);
|
||||
assert_eq!(c[0], &v[..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||
|
||||
use crate::imap::client::{ConnectMode, ImapClient};
|
||||
use crate::imap::command;
|
||||
use crate::imap::error::ImapError;
|
||||
use crate::imap::name::encode_mailbox_name_with;
|
||||
use crate::imap::response::Untagged;
|
||||
use crate::imap::retry::{BackoffState, Disposition, RetryPolicy, classify};
|
||||
use crate::imap::transport::Connector;
|
||||
|
||||
use super::coordinator::{Endpoint, ImapAuth, authenticate_client};
|
||||
use super::fetch::FetchAttrs;
|
||||
|
||||
pub const HARD_CAP: usize = 8;
|
||||
|
||||
pub struct FetchJob {
|
||||
pub folder: String,
|
||||
pub uidvalidity: u32,
|
||||
pub uids: Vec<u32>,
|
||||
}
|
||||
|
||||
pub enum FetchEvent {
|
||||
Item {
|
||||
folder: String,
|
||||
uidvalidity: u32,
|
||||
attrs: FetchAttrs,
|
||||
},
|
||||
ChunkDone {
|
||||
folder: String,
|
||||
uidvalidity: u32,
|
||||
uids_requested: Vec<u32>,
|
||||
outcome: Result<(), ImapError>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct WorkerArgs {
|
||||
pub connector: Arc<Connector>,
|
||||
pub endpoint: Arc<Endpoint>,
|
||||
pub mode: ConnectMode,
|
||||
pub auth: ImapAuth,
|
||||
pub compress: bool,
|
||||
pub policy: RetryPolicy,
|
||||
pub backoff: BackoffState,
|
||||
}
|
||||
|
||||
pub struct WorkerPool {
|
||||
job_tx: Sender<FetchJob>,
|
||||
event_rx: Receiver<FetchEvent>,
|
||||
handles: Vec<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl WorkerPool {
|
||||
pub fn start(args: WorkerArgs, pool_size: usize) -> Result<WorkerPool, ImapError> {
|
||||
let size = pool_size.clamp(1, HARD_CAP);
|
||||
let (job_tx, job_rx) = unbounded::<FetchJob>();
|
||||
let (event_tx, event_rx) = unbounded::<FetchEvent>();
|
||||
let mut handles = Vec::with_capacity(size);
|
||||
let args = Arc::new(args);
|
||||
|
||||
for _ in 0..size {
|
||||
let args = args.clone();
|
||||
let job_rx = job_rx.clone();
|
||||
let event_tx = event_tx.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
worker_loop(args, job_rx, event_tx);
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
Ok(WorkerPool {
|
||||
job_tx,
|
||||
event_rx,
|
||||
handles,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn submit(&self, job: FetchJob) {
|
||||
let _ = self.job_tx.send(job);
|
||||
}
|
||||
|
||||
pub fn recv(&self) -> Result<FetchEvent, crossbeam_channel::RecvError> {
|
||||
self.event_rx.recv()
|
||||
}
|
||||
|
||||
pub fn recv_timeout(
|
||||
&self,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<FetchEvent, crossbeam_channel::RecvTimeoutError> {
|
||||
self.event_rx.recv_timeout(timeout)
|
||||
}
|
||||
|
||||
pub fn shutdown(self) {
|
||||
drop(self.job_tx);
|
||||
for h in self.handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_loop(args: Arc<WorkerArgs>, job_rx: Receiver<FetchJob>, event_tx: Sender<FetchEvent>) {
|
||||
let mut client: Option<ImapClient> = None;
|
||||
let mut current_folder: Option<String> = None;
|
||||
while let Ok(job) = job_rx.recv() {
|
||||
let job_folder = job.folder.clone();
|
||||
let job_uv = job.uidvalidity;
|
||||
let job_uids = job.uids.clone();
|
||||
let event_tx_for_job = event_tx.clone();
|
||||
let outcome = match catch_unwind(AssertUnwindSafe(|| {
|
||||
run_job_with_retry(
|
||||
&args,
|
||||
&mut client,
|
||||
&mut current_folder,
|
||||
&job,
|
||||
&event_tx_for_job,
|
||||
)
|
||||
})) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
client = None;
|
||||
current_folder = None;
|
||||
Err(ImapError::Protocol("worker thread panicked".into()))
|
||||
}
|
||||
};
|
||||
let _ = event_tx.send(FetchEvent::ChunkDone {
|
||||
folder: job_folder,
|
||||
uidvalidity: job_uv,
|
||||
uids_requested: job_uids,
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn run_job_with_retry(
|
||||
args: &WorkerArgs,
|
||||
client_slot: &mut Option<ImapClient>,
|
||||
current_folder: &mut Option<String>,
|
||||
job: &FetchJob,
|
||||
event_tx: &Sender<FetchEvent>,
|
||||
) -> Result<(), ImapError> {
|
||||
let mut transient_attempts: u32 = 0;
|
||||
let mut transport_attempts: u32 = 0;
|
||||
loop {
|
||||
if client_slot.is_none() {
|
||||
match connect_and_auth(args) {
|
||||
Ok(c) => {
|
||||
*client_slot = Some(c);
|
||||
*current_folder = None;
|
||||
}
|
||||
Err(e) => {
|
||||
let disp = classify(&e);
|
||||
if disp == Disposition::TransportDrop
|
||||
&& transport_attempts < args.policy.max_retries
|
||||
{
|
||||
transport_attempts += 1;
|
||||
std::thread::sleep(args.backoff.transport_delay(transport_attempts));
|
||||
continue;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(client) = client_slot.as_mut() else {
|
||||
return Err(ImapError::Protocol(
|
||||
"worker pool: client slot empty after connect".into(),
|
||||
));
|
||||
};
|
||||
match run_one_job(client, current_folder, job, event_tx) {
|
||||
Ok(()) => {
|
||||
args.backoff.reset();
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => match classify(&e) {
|
||||
Disposition::TransportDrop => {
|
||||
*client_slot = None;
|
||||
*current_folder = None;
|
||||
if transport_attempts >= args.policy.max_retries {
|
||||
return Err(e);
|
||||
}
|
||||
transport_attempts += 1;
|
||||
std::thread::sleep(args.backoff.transport_delay(transport_attempts));
|
||||
}
|
||||
Disposition::Transient => {
|
||||
if transient_attempts >= args.policy.max_retries {
|
||||
return Err(e);
|
||||
}
|
||||
transient_attempts += 1;
|
||||
std::thread::sleep(args.backoff.next_shared_delay());
|
||||
}
|
||||
_ => return Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_and_auth(args: &WorkerArgs) -> Result<ImapClient, ImapError> {
|
||||
let mut client = ImapClient::connect(
|
||||
&args.connector,
|
||||
&args.endpoint.host,
|
||||
args.endpoint.port,
|
||||
args.mode,
|
||||
)?;
|
||||
authenticate_client(&mut client, &args.auth)
|
||||
.map_err(|e| ImapError::AuthFailed(e.to_string()))?;
|
||||
let _ = client.refresh_capabilities();
|
||||
if args.compress && client.has_capability("COMPRESS=DEFLATE") {
|
||||
client.compress_deflate()?;
|
||||
}
|
||||
if client.has_capability("ENABLE") && client.has_capability("UTF8=ACCEPT") {
|
||||
let _ = client.enable(&["UTF8=ACCEPT"]);
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn run_one_job(
|
||||
client: &mut ImapClient,
|
||||
current_folder: &mut Option<String>,
|
||||
job: &FetchJob,
|
||||
event_tx: &Sender<FetchEvent>,
|
||||
) -> Result<(), ImapError> {
|
||||
if current_folder.as_deref() != Some(job.folder.as_str()) {
|
||||
let wire = encode_mailbox_name_with(&job.folder, client.utf8_accept());
|
||||
client.run_collect(&command::select(&wire))?;
|
||||
*current_folder = Some(job.folder.clone());
|
||||
}
|
||||
let set = command::format_uid_set(&job.uids, true);
|
||||
let folder = job.folder.clone();
|
||||
let uv = job.uidvalidity;
|
||||
client.run_streamed(
|
||||
&command::uid_fetch(
|
||||
&set,
|
||||
&["UID", "FLAGS", "INTERNALDATE", "RFC822.SIZE", "BODY.PEEK[]"],
|
||||
),
|
||||
|u| {
|
||||
if let Untagged::Fetch { .. } = &u
|
||||
&& let Some(attrs) = super::fetch::extract(&u)
|
||||
{
|
||||
let _ = event_tx.send(FetchEvent::Item {
|
||||
folder: folder.clone(),
|
||||
uidvalidity: uv,
|
||||
attrs,
|
||||
});
|
||||
}
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::jmap::wire::JmapId;
|
||||
|
||||
pub struct IdSetDiff {
|
||||
pub new: Vec<JmapId>,
|
||||
pub vanished: Vec<JmapId>,
|
||||
pub present: Vec<JmapId>,
|
||||
}
|
||||
|
||||
pub fn diff(server_ids: &[JmapId], local_ids: &HashSet<String>) -> IdSetDiff {
|
||||
let server_set: HashSet<&str> = server_ids.iter().map(|i| i.0.as_str()).collect();
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
let mut new = Vec::new();
|
||||
let mut present = Vec::new();
|
||||
for id in server_ids {
|
||||
if !seen.insert(id.0.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if local_ids.contains(&id.0) {
|
||||
present.push(id.clone());
|
||||
} else {
|
||||
new.push(id.clone());
|
||||
}
|
||||
}
|
||||
let mut vanished = Vec::new();
|
||||
for local in local_ids {
|
||||
if !server_set.contains(local.as_str()) {
|
||||
vanished.push(JmapId(local.clone()));
|
||||
}
|
||||
}
|
||||
vanished.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
IdSetDiff {
|
||||
new,
|
||||
vanished,
|
||||
present,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ids(v: &[&str]) -> Vec<JmapId> {
|
||||
v.iter().map(|s| JmapId((*s).to_owned())).collect()
|
||||
}
|
||||
|
||||
fn locals(v: &[&str]) -> HashSet<String> {
|
||||
v.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partitions_new_vanished_present() {
|
||||
let d = diff(&ids(&["a", "b", "c"]), &locals(&["b", "c", "d"]));
|
||||
assert_eq!(d.new, ids(&["a"]));
|
||||
assert_eq!(d.present, ids(&["b", "c"]));
|
||||
assert_eq!(d.vanished, ids(&["d"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_local_means_all_new() {
|
||||
let d = diff(&ids(&["x", "y"]), &locals(&[]));
|
||||
assert_eq!(d.new, ids(&["x", "y"]));
|
||||
assert!(d.vanished.is_empty());
|
||||
assert!(d.present.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_server_means_all_vanished() {
|
||||
let d = diff(&ids(&[]), &locals(&["p", "q"]));
|
||||
assert!(d.new.is_empty());
|
||||
assert_eq!(d.vanished, ids(&["p", "q"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_server_ids_are_collapsed() {
|
||||
let d = diff(&ids(&["a", "a", "b"]), &locals(&["a"]));
|
||||
assert_eq!(d.new, ids(&["b"]));
|
||||
assert_eq!(d.present, ids(&["a"]));
|
||||
assert!(d.vanished.is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||
|
||||
use crate::jmap::session::Limits;
|
||||
|
||||
pub fn effective_workers(requested: usize, limits: &Limits, upload: bool) -> usize {
|
||||
let server_cap = if upload {
|
||||
limits.max_concurrent_upload
|
||||
} else {
|
||||
limits.max_concurrent_requests
|
||||
};
|
||||
let cap = server_cap.max(1).min(usize::MAX as u64) as usize;
|
||||
requested.clamp(1, cap)
|
||||
}
|
||||
|
||||
pub struct Pool<J, R>
|
||||
where
|
||||
J: Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
job_tx: Option<Sender<J>>,
|
||||
result_rx: Receiver<R>,
|
||||
workers: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl<J, R> Pool<J, R>
|
||||
where
|
||||
J: Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
pub fn new<F>(threads: usize, worker: F) -> Pool<J, R>
|
||||
where
|
||||
F: Fn(J) -> R + Send + Sync + 'static,
|
||||
{
|
||||
let threads = threads.max(1);
|
||||
let (job_tx, job_rx) = unbounded::<J>();
|
||||
let (result_tx, result_rx) = unbounded::<R>();
|
||||
let worker = std::sync::Arc::new(worker);
|
||||
let mut workers = Vec::with_capacity(threads);
|
||||
for _ in 0..threads {
|
||||
let job_rx = job_rx.clone();
|
||||
let result_tx = result_tx.clone();
|
||||
let worker = worker.clone();
|
||||
workers.push(std::thread::spawn(move || {
|
||||
while let Ok(job) = job_rx.recv() {
|
||||
if result_tx.send(worker(job)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
Pool {
|
||||
job_tx: Some(job_tx),
|
||||
result_rx,
|
||||
workers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(&self, job: J) {
|
||||
if let Some(tx) = &self.job_tx {
|
||||
let _ = tx.send(job);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn results(&self) -> &Receiver<R> {
|
||||
&self.result_rx
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> Vec<R> {
|
||||
drop(self.job_tx.take());
|
||||
for handle in self.workers.drain(..) {
|
||||
let _ = handle.join();
|
||||
}
|
||||
self.result_rx.try_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<J, R> Drop for Pool<J, R>
|
||||
where
|
||||
J: Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
drop(self.job_tx.take());
|
||||
for handle in self.workers.drain(..) {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn limits(req: u64, up: u64) -> Limits {
|
||||
Limits {
|
||||
max_objects_in_get: 500,
|
||||
max_objects_in_set: 500,
|
||||
max_calls_in_request: 16,
|
||||
max_concurrent_requests: req,
|
||||
max_concurrent_upload: up,
|
||||
max_size_request: 10_000_000,
|
||||
max_size_upload: 50_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_count_is_clamped_to_server_limits() {
|
||||
let l = limits(4, 2);
|
||||
assert_eq!(effective_workers(16, &l, false), 4);
|
||||
assert_eq!(effective_workers(16, &l, true), 2);
|
||||
assert_eq!(effective_workers(1, &l, false), 1);
|
||||
assert_eq!(effective_workers(0, &l, false), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_processes_all_jobs_across_workers() {
|
||||
let pool: Pool<u64, u64> = Pool::new(4, |n| n * n);
|
||||
for n in 0..100 {
|
||||
pool.submit(n);
|
||||
}
|
||||
let mut got = pool.finish();
|
||||
got.sort_unstable();
|
||||
let expected: Vec<u64> = (0..100).map(|n| n * n).collect();
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub struct TreeOrder {
|
||||
pub order: Vec<usize>,
|
||||
pub orphans: Vec<usize>,
|
||||
pub cycle_roots: Vec<usize>,
|
||||
}
|
||||
|
||||
pub fn topo_order(
|
||||
items: &[(String, Option<String>)],
|
||||
existing_parents: &HashSet<String>,
|
||||
) -> TreeOrder {
|
||||
let index: HashMap<&str, usize> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, _))| (id.as_str(), i))
|
||||
.collect();
|
||||
|
||||
let mut order = Vec::with_capacity(items.len());
|
||||
let mut orphans = Vec::new();
|
||||
let mut cycle_roots = Vec::new();
|
||||
let mut placed: HashSet<usize> = HashSet::new();
|
||||
|
||||
loop {
|
||||
let mut progressed = false;
|
||||
for (i, (_, parent)) in items.iter().enumerate() {
|
||||
if placed.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
let ready = match parent {
|
||||
None => true,
|
||||
Some(p) => match index.get(p.as_str()) {
|
||||
Some(&pi) => placed.contains(&pi),
|
||||
None => {
|
||||
if !existing_parents.contains(p) {
|
||||
orphans.push(i);
|
||||
}
|
||||
true
|
||||
}
|
||||
},
|
||||
};
|
||||
if ready {
|
||||
order.push(i);
|
||||
placed.insert(i);
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
if placed.len() == items.len() {
|
||||
break;
|
||||
}
|
||||
if !progressed {
|
||||
let mut remaining: Vec<usize> =
|
||||
(0..items.len()).filter(|i| !placed.contains(i)).collect();
|
||||
remaining.sort_by(|&a, &b| items[a].0.cmp(&items[b].0));
|
||||
let root = remaining[0];
|
||||
cycle_roots.push(root);
|
||||
order.push(root);
|
||||
placed.insert(root);
|
||||
}
|
||||
}
|
||||
|
||||
TreeOrder {
|
||||
order,
|
||||
orphans,
|
||||
cycle_roots,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(id: &str, parent: Option<&str>) -> (String, Option<String>) {
|
||||
(id.to_owned(), parent.map(str::to_owned))
|
||||
}
|
||||
|
||||
fn empty() -> HashSet<String> {
|
||||
HashSet::new()
|
||||
}
|
||||
|
||||
fn pos(order: &[usize], items: &[(String, Option<String>)], id: &str) -> usize {
|
||||
order
|
||||
.iter()
|
||||
.position(|&i| items[i].0 == id)
|
||||
.expect("id in order")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parents_precede_children() {
|
||||
let items = vec![item("c", Some("b")), item("b", Some("a")), item("a", None)];
|
||||
let t = topo_order(&items, &empty());
|
||||
assert_eq!(t.order.len(), 3);
|
||||
assert!(pos(&t.order, &items, "a") < pos(&t.order, &items, "b"));
|
||||
assert!(pos(&t.order, &items, "b") < pos(&t.order, &items, "c"));
|
||||
assert!(t.orphans.is_empty());
|
||||
assert!(t.cycle_roots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_not_fetched_and_not_existing_is_orphan() {
|
||||
let items = vec![item("x", Some("missing"))];
|
||||
let t = topo_order(&items, &empty());
|
||||
assert_eq!(t.order, vec![0]);
|
||||
assert_eq!(t.orphans, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_already_in_db_is_not_orphan() {
|
||||
let items = vec![item("x", Some("p"))];
|
||||
let existing: HashSet<String> = ["p".to_owned()].into_iter().collect();
|
||||
let t = topo_order(&items, &existing);
|
||||
assert_eq!(t.order, vec![0]);
|
||||
assert!(t.orphans.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_is_broken_by_smallest_id() {
|
||||
let items = vec![item("b", Some("a")), item("a", Some("b"))];
|
||||
let t = topo_order(&items, &empty());
|
||||
assert_eq!(t.order.len(), 2);
|
||||
assert_eq!(t.cycle_roots.len(), 1);
|
||||
assert_eq!(items[t.cycle_roots[0]].0, "a");
|
||||
assert!(pos(&t.order, &items, "a") < pos(&t.order, &items, "b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod coordinator;
|
||||
pub mod keywords;
|
||||
pub mod messages;
|
||||
pub mod tree;
|
||||
|
||||
pub use coordinator::{MaildirImportConfig, run};
|
||||
@@ -0,0 +1,504 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use regex::Regex;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use url::Url;
|
||||
|
||||
use crate::db;
|
||||
use crate::db::sources::SourceKey;
|
||||
use crate::error::Error;
|
||||
use crate::logging::{LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
|
||||
use crate::sync::{CommonConfig, Summary, TypeCounts};
|
||||
|
||||
use super::messages;
|
||||
use super::tree;
|
||||
use super::tree::{DiscoverError, FolderFilters, ResolvedFolder};
|
||||
|
||||
pub struct MaildirImportConfig {
|
||||
pub maildir: PathBuf,
|
||||
pub include: Vec<Regex>,
|
||||
pub exclude: Vec<Regex>,
|
||||
pub folder: Vec<String>,
|
||||
pub automap: bool,
|
||||
pub include_deleted: bool,
|
||||
pub allow_source_change: bool,
|
||||
}
|
||||
|
||||
pub fn run(common: CommonConfig, config: MaildirImportConfig) -> Result<Summary, Error> {
|
||||
let logger = common.logger;
|
||||
if common.threads > 1 {
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_PROGRESS,
|
||||
"maildir importer is single-threaded; --threads value will be ignored",
|
||||
);
|
||||
}
|
||||
|
||||
let canonical = std::fs::canonicalize(&config.maildir)
|
||||
.map_err(|e| Error::Usage(format!("--maildir {:?}: {e}", config.maildir)))?;
|
||||
|
||||
warn_if_looks_like_subfolder(&canonical, logger);
|
||||
|
||||
let discovered = match tree::discover(&canonical, config.automap) {
|
||||
Ok(v) => v,
|
||||
Err(DiscoverError::NotFound(p)) => {
|
||||
return Err(Error::Usage(format!("maildir path does not exist: {p:?}")));
|
||||
}
|
||||
Err(DiscoverError::NotADirectory(p)) => {
|
||||
return Err(Error::Usage(format!("not a directory: {p:?}")));
|
||||
}
|
||||
Err(DiscoverError::NotAMaildir(p)) => {
|
||||
return Err(Error::Usage(format!(
|
||||
"not a Maildir: {p:?} (missing cur/ subdirectory)"
|
||||
)));
|
||||
}
|
||||
Err(DiscoverError::NotMaildirPlus(name)) => {
|
||||
return Err(Error::Usage(format!(
|
||||
"only Maildir++ layout is supported; found non-prefixed subfolder {name:?} \
|
||||
(subfolders must be named with a leading '.', e.g. '.{name}')"
|
||||
)));
|
||||
}
|
||||
Err(DiscoverError::Io(p, e)) => {
|
||||
return Err(Error::Usage(format!("walking {p:?}: {e}")));
|
||||
}
|
||||
};
|
||||
|
||||
let include_deleted = config.include_deleted;
|
||||
let filters = FolderFilters {
|
||||
include: config.include,
|
||||
exclude: config.exclude,
|
||||
explicit: config.folder,
|
||||
};
|
||||
if !filters.explicit.is_empty() && (!filters.include.is_empty() || !filters.exclude.is_empty())
|
||||
{
|
||||
return Err(Error::Usage(
|
||||
"--folder is mutually exclusive with --include/--exclude".to_owned(),
|
||||
));
|
||||
}
|
||||
let mut resolved = tree::apply_filters(discovered, &filters);
|
||||
tree::restore_ephemeral_parents(&mut resolved, &canonical);
|
||||
let run_flags = RunFlags { include_deleted };
|
||||
|
||||
let mut conn = db::init::open(&common.archive)?;
|
||||
let session_url = file_url_for(&canonical)?;
|
||||
let account_id = canonical.to_string_lossy().into_owned();
|
||||
let account_name = canonical
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(str::to_owned);
|
||||
|
||||
if let Some((existing_url, existing_account)) =
|
||||
db::sources::conflicting_source(&conn, "maildir", &session_url, &account_id)?
|
||||
&& !config.allow_source_change
|
||||
{
|
||||
return Err(Error::SourceChange(format!(
|
||||
"archive already records maildir source {existing_url} / {existing_account}; \
|
||||
re-run with --allow-source-change or use a fresh archive"
|
||||
)));
|
||||
}
|
||||
let source_key = SourceKey {
|
||||
kind: "maildir".to_owned(),
|
||||
session_url: session_url.clone(),
|
||||
account_id: account_id.clone(),
|
||||
};
|
||||
let source_id = if common.dry_run {
|
||||
db::sources::find_source(&conn, &source_key)?.unwrap_or(-1)
|
||||
} else {
|
||||
db::sources::upsert_source(&conn, &source_key, account_name.as_deref(), "")?
|
||||
};
|
||||
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_PROGRESS,
|
||||
&format!("maildir source {session_url}"),
|
||||
);
|
||||
|
||||
if common.dry_run {
|
||||
let summary = build_dry_run_summary(&conn, source_id, &resolved, logger)?;
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
let mut mailbox_counts = TypeCounts::default();
|
||||
let mut email_counts = TypeCounts::default();
|
||||
|
||||
let local_mailboxes = db::maildir_ids::mailbox_folders(&conn, source_id)?;
|
||||
let mailbox_local_ids = upsert_mailboxes(&mut conn, source_id, &resolved, &mut mailbox_counts)?;
|
||||
|
||||
let server_names: HashSet<&str> = resolved.iter().map(|f| f.name.as_str()).collect();
|
||||
let mut vanished: Vec<String> = local_mailboxes
|
||||
.keys()
|
||||
.filter(|n| !server_names.contains(n.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
tree::vanished_depth_sort(&mut vanished);
|
||||
if !vanished.is_empty() {
|
||||
delete_vanished_folders(
|
||||
&mut conn,
|
||||
source_id,
|
||||
&vanished,
|
||||
&mut mailbox_counts,
|
||||
&mut email_counts,
|
||||
logger,
|
||||
)?;
|
||||
}
|
||||
|
||||
for folder in &resolved {
|
||||
if folder.ephemeral {
|
||||
continue;
|
||||
}
|
||||
let mailbox_local = match mailbox_local_ids.get(&folder.name).copied() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!("folder {:?}: missing from mailbox id map", folder.name),
|
||||
);
|
||||
email_counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = reconcile_folder(
|
||||
&mut conn,
|
||||
source_id,
|
||||
folder,
|
||||
mailbox_local,
|
||||
&run_flags,
|
||||
&mut email_counts,
|
||||
logger,
|
||||
) {
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!("folder {:?}: {e}", folder.name),
|
||||
);
|
||||
email_counts.failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if email_counts.failed == 0 && mailbox_counts.failed == 0 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
db::blobs::gc_orphan_blobs(&tx)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!(
|
||||
"maildir: mailbox created={} deleted={} skipped={} failed={}; \
|
||||
email created={} fetched={} deleted={} skipped={} failed={}",
|
||||
mailbox_counts.created,
|
||||
mailbox_counts.deleted,
|
||||
mailbox_counts.skipped,
|
||||
mailbox_counts.failed,
|
||||
email_counts.created,
|
||||
email_counts.fetched,
|
||||
email_counts.deleted,
|
||||
email_counts.skipped,
|
||||
email_counts.failed
|
||||
),
|
||||
);
|
||||
|
||||
Ok(Summary {
|
||||
per_type: vec![("mailbox", mailbox_counts), ("email", email_counts)],
|
||||
retries_observed: 0,
|
||||
retry_after_sleeps: 0,
|
||||
})
|
||||
}
|
||||
|
||||
struct RunFlags {
|
||||
include_deleted: bool,
|
||||
}
|
||||
|
||||
fn file_url_for(p: &Path) -> Result<String, Error> {
|
||||
Url::from_file_path(p).map(|u| u.to_string()).map_err(|_| {
|
||||
Error::Usage(format!(
|
||||
"cannot encode maildir path as a file:// URL: {p:?}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn warn_if_looks_like_subfolder(canonical: &Path, logger: Logger) {
|
||||
let Some(basename) = canonical.file_name().and_then(|n| n.to_str()) else {
|
||||
return;
|
||||
};
|
||||
if !basename.starts_with('.') {
|
||||
return;
|
||||
}
|
||||
let Some(parent) = canonical.parent() else {
|
||||
return;
|
||||
};
|
||||
if !parent.join("cur").is_dir() {
|
||||
return;
|
||||
}
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!(
|
||||
"warning: {canonical:?} looks like a Maildir++ subfolder of {parent:?} \
|
||||
(basename starts with '.' and the parent has its own cur/); proceeding, \
|
||||
but you may have meant to import the parent"
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn upsert_mailboxes(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
folders: &[ResolvedFolder],
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<HashMap<String, i64>, Error> {
|
||||
let tx = conn.transaction()?;
|
||||
let mut local_ids: HashMap<String, i64> = HashMap::new();
|
||||
for folder in folders {
|
||||
let parent_local = match folder.parent_path.as_deref() {
|
||||
Some(p) => local_ids.get(p).copied().or_else(|| {
|
||||
db::maildir_ids::local_for_mailbox(&tx, source_id, p)
|
||||
.ok()
|
||||
.flatten()
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
let is_subscribed: i64 = if folder.ephemeral { 0 } else { 1 };
|
||||
let existing = db::maildir_ids::local_for_mailbox(&tx, source_id, &folder.name)?;
|
||||
let id = if let Some(id) = existing {
|
||||
tx.execute(
|
||||
"UPDATE mailboxes SET name = ?1, parent_id = ?2, role = ?3, is_subscribed = ?4
|
||||
WHERE id = ?5",
|
||||
params![folder.leaf, parent_local, folder.role, is_subscribed, id],
|
||||
)?;
|
||||
id
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
|
||||
VALUES (?1, ?2, ?3, 0, ?4)",
|
||||
params![folder.leaf, parent_local, folder.role, is_subscribed],
|
||||
)?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
db::maildir_ids::insert_mailbox(&tx, source_id, &folder.name, new_id)?;
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
new_id
|
||||
};
|
||||
local_ids.insert(folder.name.clone(), id);
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(local_ids)
|
||||
}
|
||||
|
||||
fn delete_vanished_folders(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
folders: &[String],
|
||||
mailbox_counts: &mut TypeCounts,
|
||||
email_counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let tx = conn.transaction()?;
|
||||
for name in folders {
|
||||
let local_id = match db::maildir_ids::local_for_mailbox(&tx, source_id, name)? {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
let surviving_child: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT id FROM mailboxes WHERE parent_id = ?1 LIMIT 1",
|
||||
params![local_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if surviving_child.is_some() {
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!(
|
||||
"folder {name:?}: vanished from disk but still has child mailboxes in the \
|
||||
archive; skipping delete (parent_id RESTRICT)"
|
||||
),
|
||||
);
|
||||
mailbox_counts.failed += 1;
|
||||
continue;
|
||||
}
|
||||
let email_ids: Vec<i64> = tx
|
||||
.prepare(
|
||||
"SELECT local_id FROM sync_id_maildir
|
||||
WHERE source_id = ?1 AND type_name = 'email' AND folder = ?2",
|
||||
)?
|
||||
.query_map(params![source_id, name], |row| row.get(0))?
|
||||
.collect::<Result<Vec<i64>, _>>()?;
|
||||
for eid in &email_ids {
|
||||
tx.execute("DELETE FROM emails WHERE id = ?1", params![eid])?;
|
||||
email_counts.deleted += 1;
|
||||
}
|
||||
db::maildir_ids::delete_all_emails_in_folder(&tx, source_id, name)?;
|
||||
tx.execute("DELETE FROM mailboxes WHERE id = ?1", params![local_id])?;
|
||||
db::maildir_ids::delete_mailbox(&tx, source_id, name)?;
|
||||
mailbox_counts.deleted += 1;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconcile_folder(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
folder: &ResolvedFolder,
|
||||
mailbox_local: i64,
|
||||
flags: &RunFlags,
|
||||
counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let listing = match messages::list_folder(&folder.path) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
return Err(Error::Partial(format!(
|
||||
"list {folder:?}: {e}",
|
||||
folder = folder.path
|
||||
)));
|
||||
}
|
||||
};
|
||||
if listing.io_failures > 0 {
|
||||
counts.failed += listing.io_failures;
|
||||
}
|
||||
let local = db::maildir_ids::email_ids_in_folder(conn, source_id, &folder.name)?;
|
||||
let diff = messages::diff(listing.entries, &local);
|
||||
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_PROGRESS,
|
||||
&format!(
|
||||
"folder {:?}: new={} present={} vanished={}",
|
||||
folder.name,
|
||||
diff.new.len(),
|
||||
diff.present.len(),
|
||||
diff.vanished.len()
|
||||
),
|
||||
);
|
||||
|
||||
let ctx = messages::InsertContext {
|
||||
source_id,
|
||||
folder: &folder.name,
|
||||
mailbox_local,
|
||||
include_deleted: flags.include_deleted,
|
||||
};
|
||||
messages::apply_folder(conn, ctx, diff, counts, logger)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_dry_run_summary(
|
||||
conn: &Connection,
|
||||
source_id: i64,
|
||||
folders: &[ResolvedFolder],
|
||||
logger: Logger,
|
||||
) -> Result<Summary, Error> {
|
||||
let mut mailbox = TypeCounts::default();
|
||||
let mut email = TypeCounts::default();
|
||||
let local_mailboxes = if source_id < 0 {
|
||||
HashMap::new()
|
||||
} else {
|
||||
db::maildir_ids::mailbox_folders(conn, source_id)?
|
||||
};
|
||||
let server_set: HashSet<&str> = folders.iter().map(|f| f.name.as_str()).collect();
|
||||
let new_folders: Vec<&str> = folders
|
||||
.iter()
|
||||
.map(|f| f.name.as_str())
|
||||
.filter(|n| !local_mailboxes.contains_key(*n))
|
||||
.collect();
|
||||
let vanished_folder_names: Vec<&str> = local_mailboxes
|
||||
.keys()
|
||||
.filter(|n| !server_set.contains(n.as_str()))
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
mailbox.created += new_folders.len() as u64;
|
||||
mailbox.fetched += new_folders.len() as u64;
|
||||
mailbox.deleted += vanished_folder_names.len() as u64;
|
||||
|
||||
for folder in folders {
|
||||
if folder.ephemeral {
|
||||
continue;
|
||||
}
|
||||
let listing = match messages::list_folder(&folder.path) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
logger.warn(&format!("dry-run list {:?}: {e}", folder.name));
|
||||
email.failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
email.failed += listing.io_failures;
|
||||
let disk_ids: HashSet<String> = listing
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.unique_id.clone())
|
||||
.collect();
|
||||
let local = if source_id < 0 {
|
||||
HashMap::new()
|
||||
} else {
|
||||
db::maildir_ids::email_ids_in_folder(conn, source_id, &folder.name)?
|
||||
};
|
||||
let local_keys: HashSet<&String> = local.keys().collect();
|
||||
let new_count = disk_ids.iter().filter(|u| !local.contains_key(*u)).count() as u64;
|
||||
let vanished_count = local_keys
|
||||
.iter()
|
||||
.filter(|k| !disk_ids.contains(k.as_str()))
|
||||
.count() as u64;
|
||||
let present_count = disk_ids.iter().filter(|u| local.contains_key(*u)).count() as u64;
|
||||
email.created += new_count;
|
||||
email.fetched += new_count;
|
||||
email.deleted += vanished_count;
|
||||
email.skipped += present_count;
|
||||
}
|
||||
for name in &vanished_folder_names {
|
||||
if source_id >= 0 {
|
||||
let n = db::maildir_ids::email_ids_in_folder(conn, source_id, name)?.len() as u64;
|
||||
email.deleted += n;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Summary {
|
||||
per_type: vec![("mailbox", mailbox), ("email", email)],
|
||||
retries_observed: 0,
|
||||
retry_after_sleeps: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn log_at(logger: Logger, level: u8, msg: &str) {
|
||||
if logger.enabled(level) {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_url_for_ascii_path_is_passthrough() {
|
||||
let p = std::path::Path::new("/home/alice/Maildir");
|
||||
let url = file_url_for(p).unwrap();
|
||||
assert_eq!(url, "file:///home/alice/Maildir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_url_for_percent_encodes_spaces_and_unicode() {
|
||||
let p = std::path::Path::new("/home/alice/My Maildir/Sübfolder");
|
||||
let url = file_url_for(p).unwrap();
|
||||
assert!(url.starts_with("file:///home/alice/My%20Maildir/"));
|
||||
assert!(url.contains("S%C3%BCbfolder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_url_for_rejects_relative_paths() {
|
||||
let p = std::path::Path::new("./relative");
|
||||
let err = file_url_for(p).unwrap_err();
|
||||
assert!(matches!(err, Error::Usage(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use mail_parser::mailbox::maildir::Flag;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Translation {
|
||||
pub keywords: Vec<String>,
|
||||
pub has_trashed_flag: bool,
|
||||
}
|
||||
|
||||
pub fn translate_flags(flags: &[Flag], include_deleted: bool) -> Translation {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut has_trashed = false;
|
||||
for f in flags {
|
||||
match f {
|
||||
Flag::Passed => push_unique(&mut out, "$forwarded"),
|
||||
Flag::Replied => push_unique(&mut out, "$answered"),
|
||||
Flag::Seen => push_unique(&mut out, "$seen"),
|
||||
Flag::Draft => push_unique(&mut out, "$draft"),
|
||||
Flag::Flagged => push_unique(&mut out, "$flagged"),
|
||||
Flag::Trashed => {
|
||||
has_trashed = true;
|
||||
if include_deleted {
|
||||
push_unique(&mut out, "$deleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
Translation {
|
||||
keywords: out,
|
||||
has_trashed_flag: has_trashed,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_unique(out: &mut Vec<String>, k: &str) {
|
||||
if !out.iter().any(|x| x == k) {
|
||||
out.push(k.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flags_from_filename(filename: &str) -> Vec<Flag> {
|
||||
let Some(part) = filename.rsplit_once("2,").map(|(_, s)| s) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for &ch in part.as_bytes() {
|
||||
match ch {
|
||||
b'P' => out.push(Flag::Passed),
|
||||
b'R' => out.push(Flag::Replied),
|
||||
b'S' => out.push(Flag::Seen),
|
||||
b'T' => out.push(Flag::Trashed),
|
||||
b'D' => out.push(Flag::Draft),
|
||||
b'F' => out.push(Flag::Flagged),
|
||||
other => {
|
||||
if !other.is_ascii_alphanumeric() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn unique_id_from_filename(filename: &str) -> &str {
|
||||
match filename.split_once(':') {
|
||||
Some((unique, _)) => unique,
|
||||
None => filename,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_flags_yields_empty_keywords() {
|
||||
let t = translate_flags(&[], false);
|
||||
assert!(t.keywords.is_empty());
|
||||
assert!(!t.has_trashed_flag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_flag_maps_to_expected_jmap_keyword() {
|
||||
let t = translate_flags(
|
||||
&[
|
||||
Flag::Passed,
|
||||
Flag::Replied,
|
||||
Flag::Seen,
|
||||
Flag::Draft,
|
||||
Flag::Flagged,
|
||||
],
|
||||
false,
|
||||
);
|
||||
let mut expected = vec!["$forwarded", "$answered", "$seen", "$draft", "$flagged"];
|
||||
expected.sort();
|
||||
let expected: Vec<String> = expected.into_iter().map(str::to_owned).collect();
|
||||
assert_eq!(t.keywords, expected);
|
||||
assert!(!t.has_trashed_flag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trashed_dropped_by_default_marks_flag() {
|
||||
let t = translate_flags(&[Flag::Trashed, Flag::Seen], false);
|
||||
assert!(t.has_trashed_flag);
|
||||
assert_eq!(t.keywords, vec!["$seen".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trashed_with_include_deleted_adds_dollar_deleted() {
|
||||
let t = translate_flags(&[Flag::Trashed, Flag::Seen], true);
|
||||
assert!(t.has_trashed_flag);
|
||||
let mut expected = vec!["$seen", "$deleted"];
|
||||
expected.sort();
|
||||
let expected: Vec<String> = expected.into_iter().map(str::to_owned).collect();
|
||||
assert_eq!(t.keywords, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_independent_keywords_are_stable() {
|
||||
let a = translate_flags(&[Flag::Seen, Flag::Flagged], false);
|
||||
let b = translate_flags(&[Flag::Flagged, Flag::Seen], false);
|
||||
assert_eq!(a.keywords, b.keywords);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_flags_dedup() {
|
||||
let t = translate_flags(&[Flag::Seen, Flag::Seen, Flag::Seen], false);
|
||||
assert_eq!(t.keywords, vec!["$seen".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_id_strips_at_first_colon() {
|
||||
assert_eq!(
|
||||
unique_id_from_filename("1739471123.M001P01234V0.host:2,RS"),
|
||||
"1739471123.M001P01234V0.host"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_id_no_colon_is_whole_basename() {
|
||||
assert_eq!(
|
||||
unique_id_from_filename("1739471123.M001P0.host"),
|
||||
"1739471123.M001P0.host"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_id_strips_legacy_one_form() {
|
||||
assert_eq!(
|
||||
unique_id_from_filename("1234.M5.host:1,experimental"),
|
||||
"1234.M5.host"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_from_filename_recognises_full_set() {
|
||||
let mut flags = flags_from_filename("name:2,DFPRST");
|
||||
flags.sort();
|
||||
let mut want = vec![
|
||||
Flag::Draft,
|
||||
Flag::Flagged,
|
||||
Flag::Passed,
|
||||
Flag::Replied,
|
||||
Flag::Seen,
|
||||
Flag::Trashed,
|
||||
];
|
||||
want.sort();
|
||||
assert_eq!(flags, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_from_filename_no_info_section_yields_empty() {
|
||||
assert!(flags_from_filename("plain").is_empty());
|
||||
assert!(flags_from_filename("name:").is_empty());
|
||||
assert!(flags_from_filename("name:1,X").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_from_filename_dovecot_extension_metadata_passes_through() {
|
||||
|
||||
let mut flags = flags_from_filename("uid,S=1234,W=1300:2,RS");
|
||||
flags.sort();
|
||||
let mut want = vec![Flag::Replied, Flag::Seen];
|
||||
want.sort();
|
||||
assert_eq!(flags, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_from_filename_unknown_alpha_chars_skipped() {
|
||||
|
||||
let mut flags = flags_from_filename("uid:2,SaT");
|
||||
flags.sort();
|
||||
let mut want = vec![Flag::Seen, Flag::Trashed];
|
||||
want.sort();
|
||||
assert_eq!(flags, want);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
use mail_parser::mailbox::maildir::Flag;
|
||||
use rusqlite::{Connection, params};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::db;
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::emailmeta::email_index_from_blob;
|
||||
use crate::sync::keys::index_to_json;
|
||||
|
||||
use super::keywords::{Translation, flags_from_filename, translate_flags, unique_id_from_filename};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskEntry {
|
||||
pub unique_id: String,
|
||||
pub filename: String,
|
||||
pub path: PathBuf,
|
||||
pub flags: Vec<Flag>,
|
||||
pub mtime_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiskListing {
|
||||
pub entries: Vec<DiskEntry>,
|
||||
pub io_failures: u64,
|
||||
}
|
||||
|
||||
pub fn list_folder(folder_path: &Path) -> std::io::Result<DiskListing> {
|
||||
let mut out = DiskListing::default();
|
||||
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for sub in ["cur", "new"] {
|
||||
let dir = folder_path.join(sub);
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let entries = std::fs::read_dir(&dir)?;
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
out.io_failures += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
out.io_failures += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(raw_name) = path.file_name() else {
|
||||
continue;
|
||||
};
|
||||
let filename: String = raw_name.to_string_lossy().into_owned();
|
||||
if filename.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let unique_id = unique_id_from_filename(&filename).to_owned();
|
||||
if unique_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.contains_key(&unique_id) {
|
||||
|
||||
continue;
|
||||
}
|
||||
let flags = flags_from_filename(&filename);
|
||||
let mtime_unix = match entry.metadata().and_then(|m| m.modified()).and_then(|t| {
|
||||
t.duration_since(UNIX_EPOCH).map_err(|e| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
|
||||
})
|
||||
}) {
|
||||
Ok(d) => d.as_secs(),
|
||||
Err(_) => 0,
|
||||
};
|
||||
seen.insert(unique_id.clone(), out.entries.len());
|
||||
out.entries.push(DiskEntry {
|
||||
unique_id,
|
||||
filename,
|
||||
path,
|
||||
flags,
|
||||
mtime_unix,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FolderDiff {
|
||||
pub new: Vec<DiskEntry>,
|
||||
pub present: Vec<(DiskEntry, i64)>,
|
||||
pub vanished: Vec<(String, i64)>,
|
||||
}
|
||||
|
||||
pub fn diff(disk: Vec<DiskEntry>, local: &HashMap<String, i64>) -> FolderDiff {
|
||||
let mut diff = FolderDiff::default();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for entry in disk {
|
||||
match local.get(&entry.unique_id) {
|
||||
None => {
|
||||
seen.insert(entry.unique_id.clone());
|
||||
diff.new.push(entry);
|
||||
}
|
||||
Some(&local_id) => {
|
||||
seen.insert(entry.unique_id.clone());
|
||||
diff.present.push((entry, local_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (uid, local_id) in local {
|
||||
if !seen.contains(uid) {
|
||||
diff.vanished.push((uid.clone(), *local_id));
|
||||
}
|
||||
}
|
||||
diff.vanished.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
diff
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct InsertContext<'a> {
|
||||
pub source_id: i64,
|
||||
pub folder: &'a str,
|
||||
pub mailbox_local: i64,
|
||||
pub include_deleted: bool,
|
||||
}
|
||||
|
||||
pub fn insert_new(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
ctx: InsertContext<'_>,
|
||||
entry: &DiskEntry,
|
||||
) -> Result<Option<i64>, InsertError> {
|
||||
let bytes = std::fs::read(&entry.path).map_err(InsertError::Io)?;
|
||||
let translation = translate_flags(&entry.flags, ctx.include_deleted);
|
||||
if translation.has_trashed_flag && !ctx.include_deleted {
|
||||
return Ok(None);
|
||||
}
|
||||
let received_at = format_unix_rfc3339(entry.mtime_unix);
|
||||
let blob_id = db::blobs::intern_blob(tx, &bytes)?;
|
||||
let message_match = index_to_json(&email_index_from_blob(&bytes));
|
||||
let mailbox_ids = Value::Array(vec![Value::from(ctx.mailbox_local)]);
|
||||
let keywords = keywords_json(&translation);
|
||||
tx.execute(
|
||||
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords, message_match)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
blob_id,
|
||||
received_at,
|
||||
mailbox_ids.to_string(),
|
||||
keywords,
|
||||
message_match
|
||||
],
|
||||
)?;
|
||||
let local_id = tx.last_insert_rowid();
|
||||
db::maildir_ids::insert_email(tx, ctx.source_id, ctx.folder, &entry.unique_id, local_id)?;
|
||||
Ok(Some(local_id))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PresentOutcome {
|
||||
|
||||
Unchanged,
|
||||
|
||||
KeywordsUpdated,
|
||||
|
||||
DeleteRequested,
|
||||
}
|
||||
|
||||
pub fn refresh_flags(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
local_id: i64,
|
||||
entry: &DiskEntry,
|
||||
stored_keywords: &str,
|
||||
include_deleted: bool,
|
||||
) -> Result<PresentOutcome, rusqlite::Error> {
|
||||
let translation = translate_flags(&entry.flags, include_deleted);
|
||||
if translation.has_trashed_flag && !include_deleted {
|
||||
return Ok(PresentOutcome::DeleteRequested);
|
||||
}
|
||||
let expected = keywords_json(&translation);
|
||||
if stored_keywords == expected {
|
||||
return Ok(PresentOutcome::Unchanged);
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE emails SET keywords = ?1 WHERE id = ?2",
|
||||
params![expected, local_id],
|
||||
)?;
|
||||
Ok(PresentOutcome::KeywordsUpdated)
|
||||
}
|
||||
|
||||
pub fn load_present_keywords(
|
||||
conn: &Connection,
|
||||
source_id: i64,
|
||||
folder: &str,
|
||||
) -> Result<HashMap<i64, String>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT e.id, e.keywords FROM emails e
|
||||
JOIN sync_id_maildir m ON m.local_id = e.id AND m.type_name = 'email'
|
||||
WHERE m.source_id = ?1 AND m.folder = ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![source_id, folder], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
let mut out = HashMap::new();
|
||||
for r in rows {
|
||||
let (id, kw) = r?;
|
||||
out.insert(id, kw);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn delete_vanished(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
source_id: i64,
|
||||
folder: &str,
|
||||
unique_id: &str,
|
||||
local_id: i64,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
tx.execute("DELETE FROM emails WHERE id = ?1", params![local_id])?;
|
||||
db::maildir_ids::delete_email(tx, source_id, folder, unique_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const PROGRESS_TICK: u64 = 1000;
|
||||
|
||||
pub fn apply_folder(
|
||||
conn: &mut Connection,
|
||||
ctx: InsertContext<'_>,
|
||||
diff: FolderDiff,
|
||||
counts: &mut TypeCounts,
|
||||
logger: crate::logging::Logger,
|
||||
) -> Result<(), crate::error::Error> {
|
||||
let stored_keywords =
|
||||
load_present_keywords(conn, ctx.source_id, ctx.folder).unwrap_or_default();
|
||||
let tx = conn.transaction()?;
|
||||
let total_new = diff.new.len() as u64;
|
||||
let mut inserted: u64 = 0;
|
||||
for entry in &diff.new {
|
||||
match insert_new(&tx, ctx, entry) {
|
||||
Ok(Some(_)) => {
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
inserted += 1;
|
||||
if inserted.is_multiple_of(PROGRESS_TICK)
|
||||
&& logger.enabled(crate::logging::LEVEL_PROGRESS)
|
||||
{
|
||||
eprintln!(
|
||||
"folder {folder:?}: inserted {inserted}/{total_new}",
|
||||
folder = ctx.folder
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
counts.skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"maildir {folder:?}/{name}: {e}",
|
||||
folder = ctx.folder,
|
||||
name = entry.filename
|
||||
));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (entry, local_id) in &diff.present {
|
||||
let stored = stored_keywords
|
||||
.get(local_id)
|
||||
.map(String::as_str)
|
||||
.unwrap_or("[]");
|
||||
match refresh_flags(&tx, *local_id, entry, stored, ctx.include_deleted) {
|
||||
Ok(PresentOutcome::Unchanged) => counts.skipped += 1,
|
||||
Ok(PresentOutcome::KeywordsUpdated) => counts.fetched += 1,
|
||||
Ok(PresentOutcome::DeleteRequested) => {
|
||||
match delete_vanished(&tx, ctx.source_id, ctx.folder, &entry.unique_id, *local_id) {
|
||||
Ok(()) => counts.deleted += 1,
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"maildir {folder:?}/{name}: T-flag delete failed: {e}",
|
||||
folder = ctx.folder,
|
||||
name = entry.filename
|
||||
));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!(
|
||||
"maildir {folder:?}/{name}: flag update failed: {e}",
|
||||
folder = ctx.folder,
|
||||
name = entry.filename
|
||||
));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (unique_id, local_id) in &diff.vanished {
|
||||
if let Err(e) = delete_vanished(&tx, ctx.source_id, ctx.folder, unique_id, *local_id) {
|
||||
logger.warn(&format!(
|
||||
"maildir {folder:?}/{unique_id}: delete failed: {e}",
|
||||
folder = ctx.folder
|
||||
));
|
||||
counts.failed += 1;
|
||||
} else {
|
||||
counts.deleted += 1;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keywords_json(translation: &Translation) -> String {
|
||||
Value::Array(
|
||||
translation
|
||||
.keywords
|
||||
.iter()
|
||||
.map(|k| Value::String(k.clone()))
|
||||
.collect(),
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn format_unix_rfc3339(secs: u64) -> String {
|
||||
let when = UNIX_EPOCH + Duration::from_secs(secs);
|
||||
OffsetDateTime::from(when)
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum InsertError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("db: {0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::init;
|
||||
use crate::db::sources::{SourceKey, upsert_source};
|
||||
use std::fs;
|
||||
|
||||
fn fresh_archive() -> (Connection, i64) {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init::apply_schema(&c).unwrap();
|
||||
let sid = upsert_source(
|
||||
&c,
|
||||
&SourceKey {
|
||||
kind: "maildir".to_owned(),
|
||||
session_url: "file:///tmp/Maildir".to_owned(),
|
||||
account_id: "/tmp/Maildir".to_owned(),
|
||||
},
|
||||
Some("Maildir"),
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
(c, sid)
|
||||
}
|
||||
|
||||
fn write_maildir_message(
|
||||
folder_path: &Path,
|
||||
sub: &str,
|
||||
filename: &str,
|
||||
body: &[u8],
|
||||
) -> PathBuf {
|
||||
let dir = folder_path.join(sub);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join(filename);
|
||||
fs::write(&p, body).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn ensure_folder_skel(folder_path: &Path) {
|
||||
for sub in ["cur", "new", "tmp"] {
|
||||
fs::create_dir_all(folder_path.join(sub)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_folder_returns_cur_and_new_skipping_tmp() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
ensure_folder_skel(td.path());
|
||||
write_maildir_message(td.path(), "cur", "1.M0.host:2,S", b"a");
|
||||
write_maildir_message(td.path(), "new", "2.M0.host", b"b");
|
||||
write_maildir_message(td.path(), "tmp", "3.M0.host", b"c");
|
||||
let listing = list_folder(td.path()).unwrap();
|
||||
assert_eq!(listing.entries.len(), 2);
|
||||
assert!(listing.entries.iter().any(|e| e.unique_id == "1.M0.host"));
|
||||
assert!(listing.entries.iter().any(|e| e.unique_id == "2.M0.host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_folder_skips_dotfiles() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
ensure_folder_skel(td.path());
|
||||
write_maildir_message(td.path(), "cur", ".uidvalidity", b"meta");
|
||||
write_maildir_message(td.path(), "cur", ".dovecot-uidlist", b"meta");
|
||||
write_maildir_message(td.path(), "cur", "1.M0.host:2,S", b"a");
|
||||
let listing = list_folder(td.path()).unwrap();
|
||||
assert_eq!(listing.entries.len(), 1);
|
||||
assert_eq!(listing.entries[0].unique_id, "1.M0.host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_folder_parses_dovecot_extension_filename() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
ensure_folder_skel(td.path());
|
||||
write_maildir_message(
|
||||
td.path(),
|
||||
"cur",
|
||||
"1739471123.M001P01234V0000I0000abcd.host,S=42,W=44:2,RS",
|
||||
b"a",
|
||||
);
|
||||
let listing = list_folder(td.path()).unwrap();
|
||||
assert_eq!(listing.entries.len(), 1);
|
||||
let e = &listing.entries[0];
|
||||
assert_eq!(
|
||||
e.unique_id,
|
||||
"1739471123.M001P01234V0000I0000abcd.host,S=42,W=44"
|
||||
);
|
||||
assert!(e.flags.contains(&Flag::Replied));
|
||||
assert!(e.flags.contains(&Flag::Seen));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn list_folder_renders_non_utf8_filename_lossy() {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
ensure_folder_skel(td.path());
|
||||
let mut bytes = b"1.M0.host".to_vec();
|
||||
bytes.extend_from_slice(&[0xff, 0xfe]);
|
||||
bytes.extend_from_slice(b":2,S");
|
||||
let bad_name = OsStr::from_bytes(&bytes);
|
||||
let p = td.path().join("cur").join(bad_name);
|
||||
fs::write(&p, b"body").unwrap();
|
||||
let listing = list_folder(td.path()).unwrap();
|
||||
assert_eq!(listing.entries.len(), 1);
|
||||
let e = &listing.entries[0];
|
||||
assert!(
|
||||
e.unique_id.contains('\u{FFFD}'),
|
||||
"lossy substitution: {:?}",
|
||||
e.unique_id
|
||||
);
|
||||
assert!(e.unique_id.starts_with("1.M0.host"));
|
||||
assert!(e.flags.contains(&Flag::Seen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_partitions_new_present_vanished() {
|
||||
let entries = vec![
|
||||
DiskEntry {
|
||||
unique_id: "a".into(),
|
||||
filename: "a".into(),
|
||||
path: PathBuf::new(),
|
||||
flags: vec![],
|
||||
mtime_unix: 0,
|
||||
},
|
||||
DiskEntry {
|
||||
unique_id: "b".into(),
|
||||
filename: "b".into(),
|
||||
path: PathBuf::new(),
|
||||
flags: vec![],
|
||||
mtime_unix: 0,
|
||||
},
|
||||
];
|
||||
let mut local = HashMap::new();
|
||||
local.insert("b".to_owned(), 10);
|
||||
local.insert("c".to_owned(), 11);
|
||||
let d = diff(entries, &local);
|
||||
assert_eq!(d.new.len(), 1);
|
||||
assert_eq!(d.new[0].unique_id, "a");
|
||||
assert_eq!(d.present.len(), 1);
|
||||
assert_eq!(d.present[0].0.unique_id, "b");
|
||||
assert_eq!(d.present[0].1, 10);
|
||||
assert_eq!(d.vanished, vec![("c".to_owned(), 11)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_new_writes_blob_email_and_sync_row() {
|
||||
let (mut c, sid) = fresh_archive();
|
||||
c.execute(
|
||||
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
|
||||
VALUES ('INBOX', NULL, 'inbox', 0, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mailbox_local: i64 = c.last_insert_rowid();
|
||||
db::maildir_ids::insert_mailbox(&c, sid, "INBOX", mailbox_local).unwrap();
|
||||
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
ensure_folder_skel(td.path());
|
||||
let body = b"From: a@x\r\nSubject: hi\r\nMessage-ID: <m1@h>\r\n\r\nhi";
|
||||
write_maildir_message(td.path(), "cur", "uid1.M0.host:2,S", body);
|
||||
let listing = list_folder(td.path()).unwrap();
|
||||
let entry = listing.entries.into_iter().next().unwrap();
|
||||
|
||||
let tx = c.transaction().unwrap();
|
||||
let inserted = insert_new(
|
||||
&tx,
|
||||
InsertContext {
|
||||
source_id: sid,
|
||||
folder: "INBOX",
|
||||
mailbox_local,
|
||||
include_deleted: false,
|
||||
},
|
||||
&entry,
|
||||
)
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
assert!(inserted.is_some());
|
||||
|
||||
let email_count: i64 = c
|
||||
.query_row("SELECT count(*) FROM emails", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(email_count, 1);
|
||||
let local = db::maildir_ids::local_for_email(&c, sid, "INBOX", "uid1.M0.host")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(local, inserted.unwrap());
|
||||
let kws: String = c
|
||||
.query_row(
|
||||
"SELECT keywords FROM emails WHERE id = ?1",
|
||||
params![local],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(kws.contains("$seen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_new_skips_trashed_without_include_deleted() {
|
||||
let (mut c, sid) = fresh_archive();
|
||||
c.execute(
|
||||
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
|
||||
VALUES ('INBOX', NULL, 'inbox', 0, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mailbox_local: i64 = c.last_insert_rowid();
|
||||
db::maildir_ids::insert_mailbox(&c, sid, "INBOX", mailbox_local).unwrap();
|
||||
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
ensure_folder_skel(td.path());
|
||||
write_maildir_message(td.path(), "cur", "uid.host:2,T", b"x");
|
||||
let listing = list_folder(td.path()).unwrap();
|
||||
let entry = listing.entries.into_iter().next().unwrap();
|
||||
let tx = c.transaction().unwrap();
|
||||
let inserted = insert_new(
|
||||
&tx,
|
||||
InsertContext {
|
||||
source_id: sid,
|
||||
folder: "INBOX",
|
||||
mailbox_local,
|
||||
include_deleted: false,
|
||||
},
|
||||
&entry,
|
||||
)
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
assert!(inserted.is_none());
|
||||
let email_count: i64 = c
|
||||
.query_row("SELECT count(*) FROM emails", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(email_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_flags_updates_only_when_changed() {
|
||||
let (mut c, sid) = fresh_archive();
|
||||
c.execute(
|
||||
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
|
||||
VALUES ('INBOX', NULL, 'inbox', 0, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mailbox_local = c.last_insert_rowid();
|
||||
db::maildir_ids::insert_mailbox(&c, sid, "INBOX", mailbox_local).unwrap();
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
ensure_folder_skel(td.path());
|
||||
write_maildir_message(td.path(), "cur", "u.host:2,", b"hi");
|
||||
let entry = list_folder(td.path()).unwrap().entries.remove(0);
|
||||
let tx = c.transaction().unwrap();
|
||||
let local = insert_new(
|
||||
&tx,
|
||||
InsertContext {
|
||||
source_id: sid,
|
||||
folder: "INBOX",
|
||||
mailbox_local,
|
||||
include_deleted: false,
|
||||
},
|
||||
&entry,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
let stored = load_present_keywords(&c, sid, "INBOX").unwrap();
|
||||
let stored_for_local = stored
|
||||
.get(&local)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "[]".to_owned());
|
||||
|
||||
let tx = c.transaction().unwrap();
|
||||
assert_eq!(
|
||||
refresh_flags(&tx, local, &entry, &stored_for_local, false).unwrap(),
|
||||
PresentOutcome::Unchanged
|
||||
);
|
||||
tx.commit().unwrap();
|
||||
|
||||
fs::rename(&entry.path, entry.path.with_file_name("u.host:2,S")).unwrap();
|
||||
let entry2 = list_folder(td.path()).unwrap().entries.remove(0);
|
||||
let tx = c.transaction().unwrap();
|
||||
assert_eq!(
|
||||
refresh_flags(&tx, local, &entry2, &stored_for_local, false).unwrap(),
|
||||
PresentOutcome::KeywordsUpdated
|
||||
);
|
||||
tx.commit().unwrap();
|
||||
let kws: String = c
|
||||
.query_row(
|
||||
"SELECT keywords FROM emails WHERE id = ?1",
|
||||
params![local],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(kws.contains("$seen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_flags_returns_delete_when_trashed_added_and_include_deleted_off() {
|
||||
let (mut c, sid) = fresh_archive();
|
||||
c.execute(
|
||||
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
|
||||
VALUES ('INBOX', NULL, 'inbox', 0, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mailbox_local = c.last_insert_rowid();
|
||||
db::maildir_ids::insert_mailbox(&c, sid, "INBOX", mailbox_local).unwrap();
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
ensure_folder_skel(td.path());
|
||||
write_maildir_message(td.path(), "cur", "u.host:2,", b"hi");
|
||||
let entry = list_folder(td.path()).unwrap().entries.remove(0);
|
||||
let tx = c.transaction().unwrap();
|
||||
let local = insert_new(
|
||||
&tx,
|
||||
InsertContext {
|
||||
source_id: sid,
|
||||
folder: "INBOX",
|
||||
mailbox_local,
|
||||
include_deleted: false,
|
||||
},
|
||||
&entry,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
fs::rename(&entry.path, entry.path.with_file_name("u.host:2,T")).unwrap();
|
||||
let entry2 = list_folder(td.path()).unwrap().entries.remove(0);
|
||||
let tx = c.transaction().unwrap();
|
||||
assert_eq!(
|
||||
refresh_flags(&tx, local, &entry2, "[]", false).unwrap(),
|
||||
PresentOutcome::DeleteRequested
|
||||
);
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_flags_keeps_trashed_with_include_deleted_on() {
|
||||
let (mut c, sid) = fresh_archive();
|
||||
c.execute(
|
||||
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
|
||||
VALUES ('INBOX', NULL, 'inbox', 0, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mailbox_local = c.last_insert_rowid();
|
||||
db::maildir_ids::insert_mailbox(&c, sid, "INBOX", mailbox_local).unwrap();
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
ensure_folder_skel(td.path());
|
||||
write_maildir_message(td.path(), "cur", "u.host:2,T", b"hi");
|
||||
let entry = list_folder(td.path()).unwrap().entries.remove(0);
|
||||
let tx = c.transaction().unwrap();
|
||||
let local = insert_new(
|
||||
&tx,
|
||||
InsertContext {
|
||||
source_id: sid,
|
||||
folder: "INBOX",
|
||||
mailbox_local,
|
||||
include_deleted: true,
|
||||
},
|
||||
&entry,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
let stored = load_present_keywords(&c, sid, "INBOX").unwrap();
|
||||
let stored_for_local = stored.get(&local).cloned().unwrap();
|
||||
let tx = c.transaction().unwrap();
|
||||
assert_eq!(
|
||||
refresh_flags(&tx, local, &entry, &stored_for_local, true).unwrap(),
|
||||
PresentOutcome::Unchanged
|
||||
);
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::imap::automap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedFolder {
|
||||
pub name: String,
|
||||
pub leaf: String,
|
||||
pub parent_path: Option<String>,
|
||||
pub path: PathBuf,
|
||||
pub role: Option<&'static str>,
|
||||
pub ephemeral: bool,
|
||||
}
|
||||
|
||||
pub struct FolderFilters {
|
||||
pub include: Vec<Regex>,
|
||||
pub exclude: Vec<Regex>,
|
||||
pub explicit: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DiscoverError {
|
||||
#[error("path does not exist: {0}")]
|
||||
NotFound(PathBuf),
|
||||
#[error("path is not a directory: {0}")]
|
||||
NotADirectory(PathBuf),
|
||||
#[error("not a Maildir: {0} (missing cur/ subdirectory)")]
|
||||
NotAMaildir(PathBuf),
|
||||
#[error(
|
||||
"only Maildir++ layout is supported: found directory {0:?} that looks like a non-prefixed \
|
||||
subfolder (subfolders must be named with a leading '.', e.g. '.Sent')"
|
||||
)]
|
||||
NotMaildirPlus(String),
|
||||
#[error("io error walking {0}: {1}")]
|
||||
Io(PathBuf, std::io::Error),
|
||||
}
|
||||
|
||||
pub fn discover(root: &Path, automap_enabled: bool) -> Result<Vec<ResolvedFolder>, DiscoverError> {
|
||||
if !root.exists() {
|
||||
return Err(DiscoverError::NotFound(root.to_path_buf()));
|
||||
}
|
||||
let meta = std::fs::metadata(root).map_err(|e| DiscoverError::Io(root.to_path_buf(), e))?;
|
||||
if !meta.is_dir() {
|
||||
return Err(DiscoverError::NotADirectory(root.to_path_buf()));
|
||||
}
|
||||
if !root.join("cur").is_dir() {
|
||||
return Err(DiscoverError::NotAMaildir(root.to_path_buf()));
|
||||
}
|
||||
let mut folders = vec![ResolvedFolder {
|
||||
name: "INBOX".to_owned(),
|
||||
leaf: "INBOX".to_owned(),
|
||||
parent_path: None,
|
||||
path: root.to_path_buf(),
|
||||
role: Some("inbox"),
|
||||
ephemeral: false,
|
||||
}];
|
||||
|
||||
let mut subfolders: Vec<(String, PathBuf)> = Vec::new();
|
||||
let entries = std::fs::read_dir(root).map_err(|e| DiscoverError::Io(root.to_path_buf(), e))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| DiscoverError::Io(root.to_path_buf(), e))?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(raw_name) = path.file_name() else {
|
||||
continue;
|
||||
};
|
||||
let name: String = raw_name.to_string_lossy().into_owned();
|
||||
if matches!(name.as_str(), "cur" | "new" | "tmp") {
|
||||
continue;
|
||||
}
|
||||
let has_cur = path.join("cur").is_dir();
|
||||
if let Some(stripped) = name.strip_prefix('.') {
|
||||
if has_cur && !stripped.is_empty() {
|
||||
subfolders.push((stripped.to_owned(), path));
|
||||
}
|
||||
} else if has_cur {
|
||||
return Err(DiscoverError::NotMaildirPlus(name));
|
||||
}
|
||||
}
|
||||
|
||||
subfolders.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let mut known: Vec<String> = vec!["INBOX".to_owned()];
|
||||
for (canonical, path) in subfolders {
|
||||
let parent_path = split_parent(&canonical);
|
||||
if let Some(p) = parent_path.as_deref() {
|
||||
ensure_ephemeral_parents(p, &mut known, &mut folders, root);
|
||||
}
|
||||
let leaf = leaf_of(&canonical);
|
||||
let role = if automap_enabled {
|
||||
automap::role_for_folder(&canonical, &[], "", true)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
folders.push(ResolvedFolder {
|
||||
name: canonical.clone(),
|
||||
leaf: leaf.to_owned(),
|
||||
parent_path,
|
||||
path,
|
||||
role,
|
||||
ephemeral: false,
|
||||
});
|
||||
known.push(canonical);
|
||||
}
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
fn ensure_ephemeral_parents(
|
||||
parent_path: &str,
|
||||
known: &mut Vec<String>,
|
||||
folders: &mut Vec<ResolvedFolder>,
|
||||
root: &Path,
|
||||
) {
|
||||
for ancestor in ancestor_chain(parent_path) {
|
||||
if known.iter().any(|n| n == &ancestor) {
|
||||
continue;
|
||||
}
|
||||
let leaf = leaf_of(&ancestor).to_owned();
|
||||
let parent_path = split_parent(&ancestor);
|
||||
folders.push(ResolvedFolder {
|
||||
name: ancestor.clone(),
|
||||
leaf,
|
||||
parent_path,
|
||||
path: root.to_path_buf(),
|
||||
role: None,
|
||||
ephemeral: true,
|
||||
});
|
||||
known.push(ancestor);
|
||||
}
|
||||
}
|
||||
|
||||
fn ancestor_chain(name: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut acc = String::new();
|
||||
for part in name.split('.') {
|
||||
if !acc.is_empty() {
|
||||
acc.push('.');
|
||||
}
|
||||
acc.push_str(part);
|
||||
out.push(acc.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn split_parent(name: &str) -> Option<String> {
|
||||
name.rfind('.').map(|i| name[..i].to_owned())
|
||||
}
|
||||
|
||||
fn leaf_of(name: &str) -> &str {
|
||||
match name.rfind('.') {
|
||||
Some(i) => &name[i + 1..],
|
||||
None => name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_filters(folders: Vec<ResolvedFolder>, filters: &FolderFilters) -> Vec<ResolvedFolder> {
|
||||
folders
|
||||
.into_iter()
|
||||
.filter(|f| match_filters(&f.name, filters))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn restore_ephemeral_parents(folders: &mut Vec<ResolvedFolder>, root: &Path) {
|
||||
let known: std::collections::HashSet<String> = folders.iter().map(|f| f.name.clone()).collect();
|
||||
let mut to_add: Vec<ResolvedFolder> = Vec::new();
|
||||
for f in folders.iter() {
|
||||
if let Some(parent) = f.parent_path.as_deref() {
|
||||
for ancestor in ancestor_chain(parent) {
|
||||
if known.contains(&ancestor) || to_add.iter().any(|x| x.name == ancestor) {
|
||||
continue;
|
||||
}
|
||||
let leaf = leaf_of(&ancestor).to_owned();
|
||||
let parent_path = split_parent(&ancestor);
|
||||
to_add.push(ResolvedFolder {
|
||||
name: ancestor,
|
||||
leaf,
|
||||
parent_path,
|
||||
path: root.to_path_buf(),
|
||||
role: None,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if to_add.is_empty() {
|
||||
return;
|
||||
}
|
||||
folders.extend(to_add);
|
||||
folders.sort_by_key(|f| f.name.matches('.').count());
|
||||
}
|
||||
|
||||
fn match_filters(name: &str, filters: &FolderFilters) -> bool {
|
||||
if !filters.explicit.is_empty() {
|
||||
return filters.explicit.iter().any(|e| e == name);
|
||||
}
|
||||
if !filters.include.is_empty() && !filters.include.iter().any(|r| r.is_match(name)) {
|
||||
return false;
|
||||
}
|
||||
if filters.exclude.iter().any(|r| r.is_match(name)) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn vanished_folders<'a, I>(local: I, server: &[ResolvedFolder]) -> Vec<String>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
let server_set: std::collections::HashSet<&str> =
|
||||
server.iter().map(|f| f.name.as_str()).collect();
|
||||
local
|
||||
.into_iter()
|
||||
.filter(|n| !server_set.contains(*n))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn vanished_depth_sort(names: &mut [String]) {
|
||||
names.sort_by(|a, b| {
|
||||
let da = a.matches('.').count();
|
||||
let db = b.matches('.').count();
|
||||
db.cmp(&da).then_with(|| a.cmp(b))
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn make_maildir(root: &Path, folders: &[&str]) {
|
||||
for sub in ["cur", "new", "tmp"] {
|
||||
fs::create_dir_all(root.join(sub)).unwrap();
|
||||
}
|
||||
for name in folders {
|
||||
let p = root.join(name);
|
||||
for sub in ["cur", "new", "tmp"] {
|
||||
fs::create_dir_all(p.join(sub)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_just_inbox_when_root_alone() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
make_maildir(td.path(), &[]);
|
||||
let folders = discover(td.path(), true).unwrap();
|
||||
assert_eq!(folders.len(), 1);
|
||||
assert_eq!(folders[0].name, "INBOX");
|
||||
assert_eq!(folders[0].role, Some("inbox"));
|
||||
assert!(folders[0].parent_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_dovecot_style_subfolders() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
make_maildir(td.path(), &[".Sent", ".Drafts", ".Trash", ".Junk"]);
|
||||
let folders = discover(td.path(), true).unwrap();
|
||||
let names: Vec<&str> = folders.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"INBOX"));
|
||||
assert!(names.contains(&"Sent"));
|
||||
assert!(names.contains(&"Drafts"));
|
||||
let sent = folders.iter().find(|f| f.name == "Sent").unwrap();
|
||||
assert_eq!(sent.role, Some("sent"));
|
||||
let drafts = folders.iter().find(|f| f.name == "Drafts").unwrap();
|
||||
assert_eq!(drafts.role, Some("drafts"));
|
||||
let trash = folders.iter().find(|f| f.name == "Trash").unwrap();
|
||||
assert_eq!(trash.role, Some("trash"));
|
||||
let junk = folders.iter().find(|f| f.name == "Junk").unwrap();
|
||||
assert_eq!(junk.role, Some("junk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_courier_maildir_plus_deep_hierarchy() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
make_maildir(td.path(), &[".Archive", ".Archive.2024", ".Archive.2025"]);
|
||||
let folders = discover(td.path(), true).unwrap();
|
||||
let names: Vec<&str> = folders.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"Archive"));
|
||||
assert!(names.contains(&"Archive.2024"));
|
||||
assert!(names.contains(&"Archive.2025"));
|
||||
let a2025 = folders.iter().find(|f| f.name == "Archive.2025").unwrap();
|
||||
assert_eq!(a2025.leaf, "2025");
|
||||
assert_eq!(a2025.parent_path.as_deref(), Some("Archive"));
|
||||
let archive = folders.iter().find(|f| f.name == "Archive").unwrap();
|
||||
assert_eq!(archive.role, Some("archive"));
|
||||
|
||||
assert!(a2025.role.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_inserts_ephemeral_parent_for_orphan_branch() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
make_maildir(td.path(), &[".Lists.maildir-dev"]);
|
||||
let folders = discover(td.path(), true).unwrap();
|
||||
let lists = folders.iter().find(|f| f.name == "Lists").unwrap();
|
||||
assert!(lists.ephemeral);
|
||||
assert!(lists.role.is_none());
|
||||
assert!(lists.parent_path.is_none());
|
||||
let leaf = folders
|
||||
.iter()
|
||||
.find(|f| f.name == "Lists.maildir-dev")
|
||||
.unwrap();
|
||||
assert!(!leaf.ephemeral);
|
||||
assert_eq!(leaf.parent_path.as_deref(), Some("Lists"));
|
||||
assert_eq!(leaf.leaf, "maildir-dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skips_non_dot_subfolders_silently_only_if_no_cur() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
make_maildir(td.path(), &[".Sent"]);
|
||||
|
||||
fs::create_dir(td.path().join(".dovecot.imap")).unwrap();
|
||||
fs::write(td.path().join("dovecot.index.log"), "").unwrap();
|
||||
let folders = discover(td.path(), true).unwrap();
|
||||
assert_eq!(folders.len(), 2);
|
||||
assert!(folders.iter().any(|f| f.name == "INBOX"));
|
||||
assert!(folders.iter().any(|f| f.name == "Sent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_rejects_dovecot_layout_fs_tree() {
|
||||
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
make_maildir(td.path(), &["Sent"]);
|
||||
let err = discover(td.path(), true).unwrap_err();
|
||||
assert!(matches!(err, DiscoverError::NotMaildirPlus(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_rejects_path_without_cur_subdir() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(td.path().join("new")).unwrap();
|
||||
let err = discover(td.path(), true).unwrap_err();
|
||||
assert!(matches!(err, DiscoverError::NotAMaildir(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_rejects_nonexistent_path() {
|
||||
let err = discover(Path::new("/definitely/not/a/maildir/at/all"), true).unwrap_err();
|
||||
assert!(matches!(err, DiscoverError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_returns_parents_before_children() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
make_maildir(
|
||||
td.path(),
|
||||
&[".Archive.2025", ".Archive", ".Lists.maildir-dev"],
|
||||
);
|
||||
let folders = discover(td.path(), true).unwrap();
|
||||
let index = |name: &str| folders.iter().position(|f| f.name == name).unwrap();
|
||||
assert!(index("Archive") < index("Archive.2025"));
|
||||
assert!(index("Lists") < index("Lists.maildir-dev"));
|
||||
assert_eq!(index("INBOX"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestor_chain_walks_dot_segments() {
|
||||
assert_eq!(ancestor_chain("A"), vec!["A".to_owned()]);
|
||||
assert_eq!(
|
||||
ancestor_chain("A.B.C"),
|
||||
vec!["A".to_owned(), "A.B".to_owned(), "A.B.C".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_and_split_handle_root_and_nested() {
|
||||
assert_eq!(leaf_of("INBOX"), "INBOX");
|
||||
assert_eq!(leaf_of("Archive.2025"), "2025");
|
||||
assert_eq!(split_parent("INBOX"), None);
|
||||
assert_eq!(split_parent("Archive.2025"), Some("Archive".to_owned()));
|
||||
}
|
||||
|
||||
fn folders_for(names: &[&str]) -> Vec<ResolvedFolder> {
|
||||
names
|
||||
.iter()
|
||||
.map(|n| ResolvedFolder {
|
||||
name: (*n).to_owned(),
|
||||
leaf: (*n).to_owned(),
|
||||
parent_path: None,
|
||||
path: PathBuf::new(),
|
||||
role: None,
|
||||
ephemeral: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_filter_keeps_only_matches() {
|
||||
let folders = folders_for(&["INBOX", "Sent", "Trash"]);
|
||||
let filters = FolderFilters {
|
||||
include: vec![Regex::new("^(INBOX|Sent)$").unwrap()],
|
||||
exclude: Vec::new(),
|
||||
explicit: Vec::new(),
|
||||
};
|
||||
let kept = apply_filters(folders, &filters);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert!(kept.iter().any(|f| f.name == "INBOX"));
|
||||
assert!(kept.iter().any(|f| f.name == "Sent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_filter_drops_matches() {
|
||||
let folders = folders_for(&["INBOX", "Trash"]);
|
||||
let filters = FolderFilters {
|
||||
include: Vec::new(),
|
||||
exclude: vec![Regex::new("^Trash$").unwrap()],
|
||||
explicit: Vec::new(),
|
||||
};
|
||||
let kept = apply_filters(folders, &filters);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].name, "INBOX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_folder_overrides_include_exclude() {
|
||||
let folders = folders_for(&["INBOX", "Sent", "Trash"]);
|
||||
let filters = FolderFilters {
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
explicit: vec!["Sent".to_owned()],
|
||||
};
|
||||
let kept = apply_filters(folders, &filters);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].name, "Sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_with_automap_off_clears_roles_except_inbox() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
make_maildir(td.path(), &[".Sent", ".Drafts"]);
|
||||
let folders = discover(td.path(), false).unwrap();
|
||||
let inbox = folders.iter().find(|f| f.name == "INBOX").unwrap();
|
||||
assert_eq!(inbox.role, Some("inbox"));
|
||||
let sent = folders.iter().find(|f| f.name == "Sent").unwrap();
|
||||
assert_eq!(sent.role, None);
|
||||
let drafts = folders.iter().find(|f| f.name == "Drafts").unwrap();
|
||||
assert_eq!(drafts.role, None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn discover_renders_non_utf8_folder_name_lossy() {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
make_maildir(td.path(), &[]);
|
||||
let mut bytes = b".Foo".to_vec();
|
||||
bytes.extend_from_slice(&[0xff, 0xfe]);
|
||||
let dotted = OsStr::from_bytes(&bytes);
|
||||
let p = td.path().join(dotted);
|
||||
for sub in ["cur", "new", "tmp"] {
|
||||
fs::create_dir_all(p.join(sub)).unwrap();
|
||||
}
|
||||
let folders = discover(td.path(), true).unwrap();
|
||||
assert_eq!(folders.len(), 2);
|
||||
let sub = folders.iter().find(|f| f.name != "INBOX").unwrap();
|
||||
assert!(
|
||||
sub.name.contains('\u{FFFD}'),
|
||||
"lossy substitution: {:?}",
|
||||
sub.name
|
||||
);
|
||||
assert!(sub.name.starts_with("Foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanished_depth_sort_deepest_first() {
|
||||
let mut names = vec![
|
||||
"Archive".to_owned(),
|
||||
"Archive.2025.January".to_owned(),
|
||||
"Archive.2025".to_owned(),
|
||||
];
|
||||
vanished_depth_sort(&mut names);
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"Archive.2025.January".to_owned(),
|
||||
"Archive.2025".to_owned(),
|
||||
"Archive".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod coordinator;
|
||||
pub mod reconcile;
|
||||
|
||||
pub use coordinator::{ManageSieveAuth, ManageSieveImportConfig, run};
|
||||
@@ -0,0 +1,638 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use crate::db;
|
||||
use crate::db::sources::SourceKey;
|
||||
use crate::error::Error;
|
||||
use crate::imap::retry::BackoffState;
|
||||
use crate::imap::transport::Connector;
|
||||
use crate::logging::{LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
|
||||
use crate::managesieve::error::SieveError;
|
||||
use crate::managesieve::response::parse_listscripts;
|
||||
use crate::managesieve::retry::{Disposition, classify, is_negotiation_failure};
|
||||
use crate::managesieve::{ConnectMode, SieveClient, parse_getscript};
|
||||
use crate::sync::keys::blake3_bytes;
|
||||
use crate::sync::{CommonConfig, Summary, TypeCounts};
|
||||
|
||||
use super::reconcile::{
|
||||
Action, PresentOutcome, apply_active_assignment, apply_content_update, apply_delete, apply_new,
|
||||
load_local_state, plan,
|
||||
};
|
||||
|
||||
pub struct ManageSieveImportConfig {
|
||||
pub url: String,
|
||||
pub auth: ManageSieveAuth,
|
||||
pub allow_cleartext: bool,
|
||||
pub allow_source_change: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ManageSieveAuth {
|
||||
Basic { user: String, password: String },
|
||||
Bearer { user: String, token: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Endpoint {
|
||||
host: String,
|
||||
port: u16,
|
||||
implicit_tls: bool,
|
||||
}
|
||||
|
||||
struct ControlCtx {
|
||||
connector: Arc<Connector>,
|
||||
host: String,
|
||||
port: u16,
|
||||
mode: ConnectMode,
|
||||
allow_cleartext: bool,
|
||||
auth: ManageSieveAuth,
|
||||
backoff: BackoffState,
|
||||
max_retries: u32,
|
||||
logger: Logger,
|
||||
}
|
||||
|
||||
fn reconnect(client: &mut SieveClient, ctx: &ControlCtx) -> Result<(), SieveError> {
|
||||
let new = SieveClient::connect(
|
||||
&ctx.connector,
|
||||
&ctx.host,
|
||||
ctx.port,
|
||||
ctx.mode,
|
||||
ctx.allow_cleartext,
|
||||
)?;
|
||||
*client = new;
|
||||
match do_authenticate(client, &ctx.auth) {
|
||||
Ok(()) => {}
|
||||
Err(SieveAuthError::Wire(e)) => return Err(e),
|
||||
Err(SieveAuthError::TerminallyRefused(text)) => {
|
||||
return Err(SieveError::Protocol(format!(
|
||||
"auth refused on reconnect: {text}"
|
||||
)));
|
||||
}
|
||||
Err(SieveAuthError::NoUsableMechanism(text)) => {
|
||||
return Err(SieveError::Unsupported(text));
|
||||
}
|
||||
}
|
||||
if !client.had_fresh_post_auth_caps()
|
||||
&& let Err(e) = client.refresh_capabilities()
|
||||
{
|
||||
ctx.logger.warn(&format!(
|
||||
"post-reconnect CAPABILITY refresh failed: {e}; continuing"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(common: CommonConfig, config: ManageSieveImportConfig) -> Result<Summary, Error> {
|
||||
let logger = common.logger;
|
||||
if common.threads > 1 {
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_PROGRESS,
|
||||
"managesieve importer is single-threaded; --threads value will be ignored",
|
||||
);
|
||||
}
|
||||
|
||||
let mut conn = db::init::open(&common.archive)?;
|
||||
|
||||
let endpoint = parse_endpoint(&config.url)?;
|
||||
let session_url = format!(
|
||||
"{}://{}:{}",
|
||||
if endpoint.implicit_tls {
|
||||
"sieves"
|
||||
} else {
|
||||
"sieve"
|
||||
},
|
||||
endpoint.host,
|
||||
endpoint.port
|
||||
);
|
||||
|
||||
let connector = Arc::new(
|
||||
Connector::new(common.allow_invalid_certs).map_err(|e| Error::Connection(e.to_string()))?,
|
||||
);
|
||||
let mode = if endpoint.implicit_tls {
|
||||
ConnectMode::ImplicitTls
|
||||
} else {
|
||||
ConnectMode::StartTls
|
||||
};
|
||||
|
||||
let mut client = SieveClient::connect(
|
||||
&connector,
|
||||
&endpoint.host,
|
||||
endpoint.port,
|
||||
mode,
|
||||
config.allow_cleartext,
|
||||
)
|
||||
.map_err(|e| Error::Connection(e.to_string()))?;
|
||||
|
||||
let account_id = authenticate(&mut client, &config.auth)?;
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_PROGRESS,
|
||||
&format!("authenticated as {account_id} on {session_url}"),
|
||||
);
|
||||
if !client.had_fresh_post_auth_caps()
|
||||
&& let Err(e) = client.refresh_capabilities()
|
||||
{
|
||||
logger.warn(&format!(
|
||||
"post-auth CAPABILITY refresh failed: {e}; continuing"
|
||||
));
|
||||
}
|
||||
|
||||
let source_key = SourceKey {
|
||||
kind: "managesieve".to_owned(),
|
||||
session_url: session_url.clone(),
|
||||
account_id: account_id.clone(),
|
||||
};
|
||||
if let Some((existing_url, existing_account)) =
|
||||
db::sources::conflicting_source(&conn, "managesieve", &session_url, &account_id)?
|
||||
&& !config.allow_source_change
|
||||
{
|
||||
return Err(Error::SourceChange(format!(
|
||||
"archive already records managesieve source {existing_url} / {existing_account}; \
|
||||
re-run with --allow-source-change or use a fresh archive"
|
||||
)));
|
||||
}
|
||||
let source_id = if common.dry_run {
|
||||
db::sources::find_source(&conn, &source_key)?.unwrap_or(-1)
|
||||
} else {
|
||||
db::sources::upsert_source(&conn, &source_key, Some(&account_id), &account_id)?
|
||||
};
|
||||
|
||||
let backoff = BackoffState::new();
|
||||
let control_ctx = ControlCtx {
|
||||
connector: connector.clone(),
|
||||
host: endpoint.host.clone(),
|
||||
port: endpoint.port,
|
||||
mode,
|
||||
allow_cleartext: config.allow_cleartext,
|
||||
auth: config.auth.clone(),
|
||||
backoff: backoff.clone(),
|
||||
max_retries: common.max_retries,
|
||||
logger,
|
||||
};
|
||||
let server_listing = listscripts_with_retry(&mut client, &control_ctx)?;
|
||||
|
||||
let mut server_with_bytes: Vec<(String, bool, [u8; 32], Vec<u8>)> =
|
||||
Vec::with_capacity(server_listing.len());
|
||||
let mut counts = TypeCounts::default();
|
||||
for (name, active) in &server_listing {
|
||||
match getscript_with_retry(&mut client, &control_ctx, name) {
|
||||
Ok(bytes) => {
|
||||
let hash = blake3_bytes(&bytes);
|
||||
server_with_bytes.push((name.clone(), *active, hash, bytes));
|
||||
}
|
||||
Err(e) => match classify(&e) {
|
||||
Disposition::Permanent
|
||||
| Disposition::PerScriptRecoverable
|
||||
| Disposition::Transient => {
|
||||
logger.warn(&format!("GETSCRIPT {name:?} failed, skipping: {e}"));
|
||||
counts.failed += 1;
|
||||
}
|
||||
Disposition::Referral => {
|
||||
return Err(Error::Connection(format!(
|
||||
"ManageSieve referral on GETSCRIPT {name:?}: {e}; vandelay does not follow referrals"
|
||||
)));
|
||||
}
|
||||
Disposition::TransportDrop => {
|
||||
return Err(Error::Connection(format!(
|
||||
"GETSCRIPT {name:?} transport drop exhausted retries: {e}"
|
||||
)));
|
||||
}
|
||||
Disposition::FeatureNegotiation => {
|
||||
return Err(Error::Connection(format!("GETSCRIPT {name:?}: {e}")));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if common.dry_run {
|
||||
let summary =
|
||||
build_dry_run_summary(&conn, source_id, &server_with_bytes, counts, &backoff)?;
|
||||
let _ = client.logout();
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
let local_map = db::managesieve_ids::all_names(&conn, source_id)?;
|
||||
let (local_hashes, local_active) = load_local_state(&conn, source_id)?;
|
||||
let server_keyed: Vec<(String, bool, [u8; 32])> = server_with_bytes
|
||||
.iter()
|
||||
.map(|(n, a, h, _)| (n.clone(), *a, *h))
|
||||
.collect();
|
||||
let plan = plan(&server_keyed, &local_map, &local_hashes, &local_active);
|
||||
|
||||
let bytes_by_name: std::collections::HashMap<String, &Vec<u8>> = server_with_bytes
|
||||
.iter()
|
||||
.map(|(n, _, _, b)| (n.clone(), b))
|
||||
.collect();
|
||||
|
||||
let mut active_local: Option<i64> = None;
|
||||
|
||||
for action in &plan.actions {
|
||||
match action {
|
||||
Action::New { name, active } => {
|
||||
let bytes = bytes_by_name.get(name).cloned().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
match apply_new(&tx, source_id, name, bytes) {
|
||||
Ok(local_id) => {
|
||||
tx.commit()?;
|
||||
if *active {
|
||||
active_local = Some(local_id);
|
||||
}
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
logger.warn(&format!("INSERT sieve_scripts {name:?} failed: {e}"));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::Present {
|
||||
name,
|
||||
local_id,
|
||||
outcome,
|
||||
active,
|
||||
} => {
|
||||
match outcome {
|
||||
PresentOutcome::Unchanged => {
|
||||
counts.skipped += 1;
|
||||
}
|
||||
PresentOutcome::ActiveOnly => {
|
||||
counts.skipped += 1;
|
||||
}
|
||||
PresentOutcome::ContentUpdated => {
|
||||
let bytes = bytes_by_name.get(name).cloned().unwrap();
|
||||
let tx = conn.transaction()?;
|
||||
match apply_content_update(&tx, *local_id, bytes) {
|
||||
Ok(()) => {
|
||||
tx.commit()?;
|
||||
counts.fetched += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
logger.warn(&format!("UPDATE sieve_scripts {name:?} failed: {e}"));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if *active {
|
||||
active_local = Some(*local_id);
|
||||
}
|
||||
}
|
||||
Action::Vanished { name, local_id } => {
|
||||
let tx = conn.transaction()?;
|
||||
match apply_delete(&tx, source_id, name, *local_id) {
|
||||
Ok(()) => {
|
||||
tx.commit()?;
|
||||
counts.deleted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
logger.warn(&format!("DELETE sieve_scripts {name:?} failed: {e}"));
|
||||
counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_active_assignment(&mut conn, active_local)?;
|
||||
|
||||
let _ = client.logout();
|
||||
|
||||
if counts.failed == 0 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
db::blobs::gc_orphan_blobs(&tx)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
log_at(
|
||||
logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!(
|
||||
"managesieve: created={} fetched={} deleted={} skipped={} failed={}",
|
||||
counts.created, counts.fetched, counts.deleted, counts.skipped, counts.failed
|
||||
),
|
||||
);
|
||||
|
||||
Ok(Summary {
|
||||
per_type: vec![("sievescript", counts)],
|
||||
retries_observed: backoff.total_retries(),
|
||||
retry_after_sleeps: backoff.transient_retries() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_dry_run_summary(
|
||||
conn: &rusqlite::Connection,
|
||||
source_id: i64,
|
||||
server: &[(String, bool, [u8; 32], Vec<u8>)],
|
||||
mut counts: TypeCounts,
|
||||
backoff: &BackoffState,
|
||||
) -> Result<Summary, Error> {
|
||||
let server_keyed: Vec<(String, bool, [u8; 32])> = server
|
||||
.iter()
|
||||
.map(|(n, a, h, _)| (n.clone(), *a, *h))
|
||||
.collect();
|
||||
let (local_map, local_hashes, local_active) = if source_id < 0 {
|
||||
(
|
||||
std::collections::HashMap::new(),
|
||||
std::collections::HashMap::new(),
|
||||
std::collections::HashMap::new(),
|
||||
)
|
||||
} else {
|
||||
let local_map = db::managesieve_ids::all_names(conn, source_id)?;
|
||||
let (h, a) = load_local_state(conn, source_id)?;
|
||||
(local_map, h, a)
|
||||
};
|
||||
let plan = plan(&server_keyed, &local_map, &local_hashes, &local_active);
|
||||
for action in &plan.actions {
|
||||
match action {
|
||||
Action::New { .. } => {
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
}
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::ContentUpdated,
|
||||
..
|
||||
} => counts.fetched += 1,
|
||||
Action::Present { .. } => counts.skipped += 1,
|
||||
Action::Vanished { .. } => counts.deleted += 1,
|
||||
}
|
||||
}
|
||||
Ok(Summary {
|
||||
per_type: vec![("sievescript", counts)],
|
||||
retries_observed: backoff.total_retries(),
|
||||
retry_after_sleeps: backoff.transient_retries() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_endpoint(url: &str) -> Result<Endpoint, Error> {
|
||||
let parsed =
|
||||
Url::parse(url).map_err(|e| Error::Usage(format!("invalid --url {url:?}: {e}")))?;
|
||||
let scheme = parsed.scheme();
|
||||
let implicit_tls = match scheme {
|
||||
"sieves" => true,
|
||||
"sieve" => false,
|
||||
other => {
|
||||
return Err(Error::Usage(format!(
|
||||
"--url scheme must be sieve or sieves, got {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| Error::Usage(format!("--url missing host: {url}")))?
|
||||
.to_owned();
|
||||
let port = parsed.port().unwrap_or(4190);
|
||||
Ok(Endpoint {
|
||||
host,
|
||||
port,
|
||||
implicit_tls,
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticate(client: &mut SieveClient, auth: &ManageSieveAuth) -> Result<String, Error> {
|
||||
do_authenticate(client, auth).map_err(|e| match e {
|
||||
SieveAuthError::TerminallyRefused(text) => {
|
||||
Error::Connection(format!("auth failed: {text}"))
|
||||
}
|
||||
SieveAuthError::NoUsableMechanism(text) => Error::Connection(text),
|
||||
SieveAuthError::Wire(e) => Error::Connection(e.to_string()),
|
||||
})?;
|
||||
Ok(account_id_for(auth))
|
||||
}
|
||||
|
||||
fn account_id_for(auth: &ManageSieveAuth) -> String {
|
||||
match auth {
|
||||
ManageSieveAuth::Basic { user, .. } => user.clone(),
|
||||
ManageSieveAuth::Bearer { user, .. } => user.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SieveAuthError {
|
||||
|
||||
TerminallyRefused(String),
|
||||
|
||||
NoUsableMechanism(String),
|
||||
|
||||
Wire(SieveError),
|
||||
}
|
||||
|
||||
fn do_authenticate(client: &mut SieveClient, auth: &ManageSieveAuth) -> Result<(), SieveAuthError> {
|
||||
match auth {
|
||||
ManageSieveAuth::Basic { user, password } => {
|
||||
let caps = client.capabilities().clone();
|
||||
let mut last_plain_error: Option<String> = None;
|
||||
if caps.has_sasl("PLAIN") {
|
||||
match client.authenticate_plain(user, password) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if is_negotiation_failure(&e) => {
|
||||
last_plain_error = Some(e.to_string());
|
||||
}
|
||||
Err(e) => return Err(SieveAuthError::Wire(e)),
|
||||
}
|
||||
}
|
||||
if caps.has_sasl("LOGIN") {
|
||||
match client.authenticate_login(user, password) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if is_negotiation_failure(&e) => {
|
||||
let combined = match last_plain_error {
|
||||
Some(p) => format!("LOGIN: {e}; PLAIN: {p}"),
|
||||
None => format!("LOGIN: {e}"),
|
||||
};
|
||||
return Err(SieveAuthError::TerminallyRefused(combined));
|
||||
}
|
||||
Err(e) => return Err(SieveAuthError::Wire(e)),
|
||||
}
|
||||
}
|
||||
if let Some(p) = last_plain_error {
|
||||
return Err(SieveAuthError::TerminallyRefused(format!("PLAIN: {p}")));
|
||||
}
|
||||
Err(SieveAuthError::NoUsableMechanism(format!(
|
||||
"server SASL list lacks PLAIN and LOGIN (advertised: {:?})",
|
||||
caps.sasl
|
||||
)))
|
||||
}
|
||||
ManageSieveAuth::Bearer { user, token } => {
|
||||
if !client.capabilities().has_sasl("OAUTHBEARER") {
|
||||
return Err(SieveAuthError::NoUsableMechanism(format!(
|
||||
"server does not advertise OAUTHBEARER (advertised: {:?})",
|
||||
client.capabilities().sasl
|
||||
)));
|
||||
}
|
||||
match client.authenticate_oauthbearer(user, token) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if is_negotiation_failure(&e) => Err(SieveAuthError::TerminallyRefused(
|
||||
format!("OAUTHBEARER: {e}"),
|
||||
)),
|
||||
Err(e) => Err(SieveAuthError::Wire(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn listscripts_with_retry(
|
||||
client: &mut SieveClient,
|
||||
ctx: &ControlCtx,
|
||||
) -> Result<Vec<(String, bool)>, Error> {
|
||||
let mut transient_attempts: u32 = 0;
|
||||
let mut transport_attempts: u32 = 0;
|
||||
loop {
|
||||
match client.listscripts() {
|
||||
Ok(block) => {
|
||||
ctx.backoff.reset();
|
||||
let listed = parse_listscripts(&block.data)
|
||||
.map_err(|e| Error::Connection(format!("LISTSCRIPTS parse: {e}")))?;
|
||||
return Ok(listed.into_iter().map(|s| (s.name, s.active)).collect());
|
||||
}
|
||||
Err(e) => match classify(&e) {
|
||||
Disposition::Transient => {
|
||||
if transient_attempts >= ctx.max_retries {
|
||||
return Err(Error::Connection(format!(
|
||||
"LISTSCRIPTS retries exhausted: {e}"
|
||||
)));
|
||||
}
|
||||
transient_attempts += 1;
|
||||
log_at(
|
||||
ctx.logger,
|
||||
LEVEL_PROGRESS,
|
||||
&format!("LISTSCRIPTS transient: {e}; retrying"),
|
||||
);
|
||||
std::thread::sleep(ctx.backoff.next_shared_delay());
|
||||
}
|
||||
Disposition::TransportDrop => {
|
||||
if transport_attempts >= ctx.max_retries {
|
||||
return Err(Error::Connection(format!(
|
||||
"LISTSCRIPTS transport drop, retries exhausted: {e}"
|
||||
)));
|
||||
}
|
||||
transport_attempts += 1;
|
||||
std::thread::sleep(ctx.backoff.transport_delay(transport_attempts));
|
||||
match reconnect(client, ctx) {
|
||||
Ok(()) => {
|
||||
log_at(
|
||||
ctx.logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!("managesieve reconnected after transport drop ({e})"),
|
||||
);
|
||||
}
|
||||
Err(e2) => {
|
||||
return Err(Error::Connection(format!(
|
||||
"LISTSCRIPTS reconnect failed: {e2}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Disposition::Referral => {
|
||||
return Err(Error::Connection(format!(
|
||||
"LISTSCRIPTS referral: {e}; vandelay does not follow referrals"
|
||||
)));
|
||||
}
|
||||
_ => return Err(Error::Connection(format!("LISTSCRIPTS failed: {e}"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn getscript_with_retry(
|
||||
client: &mut SieveClient,
|
||||
ctx: &ControlCtx,
|
||||
name: &str,
|
||||
) -> Result<Vec<u8>, SieveError> {
|
||||
let mut transient_attempts: u32 = 0;
|
||||
let mut transport_attempts: u32 = 0;
|
||||
loop {
|
||||
match client.getscript(name) {
|
||||
Ok(block) => {
|
||||
ctx.backoff.reset();
|
||||
return parse_getscript(&block.data);
|
||||
}
|
||||
Err(e) => match classify(&e) {
|
||||
Disposition::Transient => {
|
||||
if transient_attempts >= ctx.max_retries {
|
||||
return Err(e);
|
||||
}
|
||||
transient_attempts += 1;
|
||||
log_at(
|
||||
ctx.logger,
|
||||
LEVEL_PROGRESS,
|
||||
&format!("GETSCRIPT {name:?} transient: {e}; retrying"),
|
||||
);
|
||||
std::thread::sleep(ctx.backoff.next_shared_delay());
|
||||
}
|
||||
Disposition::TransportDrop => {
|
||||
if transport_attempts >= ctx.max_retries {
|
||||
return Err(e);
|
||||
}
|
||||
transport_attempts += 1;
|
||||
std::thread::sleep(ctx.backoff.transport_delay(transport_attempts));
|
||||
reconnect(client, ctx)?;
|
||||
log_at(
|
||||
ctx.logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!(
|
||||
"managesieve reconnected mid-GETSCRIPT {name:?} after transport drop"
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => return Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_at(logger: Logger, level: u8, msg: &str) {
|
||||
if logger.enabled(level) {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_endpoint_sieves_defaults_to_4190() {
|
||||
let e = parse_endpoint("sieves://mail.example.com").unwrap();
|
||||
assert_eq!(e.host, "mail.example.com");
|
||||
assert_eq!(e.port, 4190);
|
||||
assert!(e.implicit_tls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_endpoint_sieve_defaults_to_4190() {
|
||||
let e = parse_endpoint("sieve://mail.example.com").unwrap();
|
||||
assert_eq!(e.host, "mail.example.com");
|
||||
assert_eq!(e.port, 4190);
|
||||
assert!(!e.implicit_tls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_endpoint_explicit_port() {
|
||||
let e = parse_endpoint("sieve://mail.example.com:14190").unwrap();
|
||||
assert_eq!(e.port, 14190);
|
||||
assert!(!e.implicit_tls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_endpoint_rejects_other_schemes() {
|
||||
let err = parse_endpoint("imap://example.com").unwrap_err();
|
||||
assert!(matches!(err, Error::Usage(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_endpoint_rejects_missing_host() {
|
||||
let err = parse_endpoint("sieve:///path").unwrap_err();
|
||||
assert!(matches!(err, Error::Usage(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::db;
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PresentOutcome {
|
||||
Unchanged,
|
||||
ActiveOnly,
|
||||
ContentUpdated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
New {
|
||||
name: String,
|
||||
active: bool,
|
||||
},
|
||||
Present {
|
||||
name: String,
|
||||
local_id: i64,
|
||||
outcome: PresentOutcome,
|
||||
active: bool,
|
||||
},
|
||||
Vanished {
|
||||
name: String,
|
||||
local_id: i64,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Plan {
|
||||
pub actions: Vec<Action>,
|
||||
}
|
||||
|
||||
pub fn plan(
|
||||
server: &[(String, bool, [u8; 32])],
|
||||
local: &HashMap<String, i64>,
|
||||
local_hashes: &HashMap<i64, [u8; 32]>,
|
||||
local_active: &HashMap<i64, bool>,
|
||||
) -> Plan {
|
||||
let mut actions = Vec::new();
|
||||
let mut server_names: HashSet<&str> = HashSet::new();
|
||||
for (name, active, hash) in server {
|
||||
server_names.insert(name.as_str());
|
||||
match local.get(name) {
|
||||
None => actions.push(Action::New {
|
||||
name: name.clone(),
|
||||
active: *active,
|
||||
}),
|
||||
Some(local_id) => {
|
||||
let cur_hash = local_hashes.get(local_id);
|
||||
let cur_active = local_active.get(local_id).copied().unwrap_or(false);
|
||||
let outcome = if cur_hash != Some(hash) {
|
||||
PresentOutcome::ContentUpdated
|
||||
} else if cur_active != *active {
|
||||
PresentOutcome::ActiveOnly
|
||||
} else {
|
||||
PresentOutcome::Unchanged
|
||||
};
|
||||
actions.push(Action::Present {
|
||||
name: name.clone(),
|
||||
local_id: *local_id,
|
||||
outcome,
|
||||
active: *active,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, local_id) in local {
|
||||
if !server_names.contains(name.as_str()) {
|
||||
actions.push(Action::Vanished {
|
||||
name: name.clone(),
|
||||
local_id: *local_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Plan { actions }
|
||||
}
|
||||
|
||||
pub type LocalState = (HashMap<i64, [u8; 32]>, HashMap<i64, bool>);
|
||||
|
||||
pub fn load_local_state(conn: &Connection, source_id: i64) -> Result<LocalState, Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT s.id, b.hash, s.is_active
|
||||
FROM sieve_scripts s JOIN blobs b ON b.id = s.blob_id
|
||||
JOIN sync_id_managesieve m ON m.local_id = s.id
|
||||
WHERE m.source_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![source_id], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let hash: Vec<u8> = row.get(1)?;
|
||||
let active: i64 = row.get(2)?;
|
||||
Ok((id, hash, active != 0))
|
||||
})?;
|
||||
let mut hashes = HashMap::new();
|
||||
let mut active = HashMap::new();
|
||||
for r in rows {
|
||||
let (id, hash, a) = r?;
|
||||
if hash.len() == 32 {
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&hash);
|
||||
hashes.insert(id, arr);
|
||||
}
|
||||
active.insert(id, a);
|
||||
}
|
||||
Ok((hashes, active))
|
||||
}
|
||||
|
||||
pub fn apply_new(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
source_id: i64,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<i64, Error> {
|
||||
let blob_id = db::blobs::intern_blob(tx, bytes)?;
|
||||
tx.execute(
|
||||
"INSERT INTO sieve_scripts (name, is_active, blob_id) VALUES (?1, 0, ?2)",
|
||||
params![name, blob_id],
|
||||
)?;
|
||||
let local_id = tx.last_insert_rowid();
|
||||
db::managesieve_ids::insert(tx, source_id, name, local_id)?;
|
||||
Ok(local_id)
|
||||
}
|
||||
|
||||
pub fn apply_content_update(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
local_id: i64,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), Error> {
|
||||
let blob_id = db::blobs::intern_blob(tx, bytes)?;
|
||||
tx.execute(
|
||||
"UPDATE sieve_scripts SET blob_id = ?1 WHERE id = ?2",
|
||||
params![blob_id, local_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_delete(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
source_id: i64,
|
||||
name: &str,
|
||||
local_id: i64,
|
||||
) -> Result<(), Error> {
|
||||
tx.execute("DELETE FROM sieve_scripts WHERE id = ?1", params![local_id])?;
|
||||
db::managesieve_ids::delete(tx, source_id, name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_active_assignment(
|
||||
conn: &mut Connection,
|
||||
new_active_local: Option<i64>,
|
||||
) -> Result<(), Error> {
|
||||
let tx = conn.transaction()?;
|
||||
let existing_active: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT id FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if existing_active != new_active_local {
|
||||
if existing_active.is_some() {
|
||||
tx.execute(
|
||||
"UPDATE sieve_scripts SET is_active = 0 WHERE is_active = 1",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
if let Some(id) = new_active_local {
|
||||
tx.execute(
|
||||
"UPDATE sieve_scripts SET is_active = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::init;
|
||||
use crate::db::sources::{SourceKey, upsert_source};
|
||||
use blake3::Hasher;
|
||||
|
||||
fn hash(b: &[u8]) -> [u8; 32] {
|
||||
let mut h = Hasher::new();
|
||||
h.update(b);
|
||||
h.finalize().into()
|
||||
}
|
||||
|
||||
fn mem() -> (Connection, i64) {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init::apply_schema(&c).unwrap();
|
||||
let sid = upsert_source(
|
||||
&c,
|
||||
&SourceKey {
|
||||
kind: "managesieve".to_owned(),
|
||||
session_url: "sieve://h:4190".to_owned(),
|
||||
account_id: "a".to_owned(),
|
||||
},
|
||||
None,
|
||||
"a",
|
||||
)
|
||||
.unwrap();
|
||||
(c, sid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_classifies_new_present_unchanged_and_vanished() {
|
||||
let h_v = hash(b"v");
|
||||
let h_w = hash(b"w");
|
||||
let server: Vec<(String, bool, [u8; 32])> = vec![
|
||||
("a".into(), false, h_v),
|
||||
("b".into(), true, h_w),
|
||||
("c".into(), false, h_v),
|
||||
];
|
||||
let mut local: HashMap<String, i64> = HashMap::new();
|
||||
local.insert("b".into(), 2);
|
||||
local.insert("c".into(), 3);
|
||||
local.insert("d".into(), 4);
|
||||
let mut lh: HashMap<i64, [u8; 32]> = HashMap::new();
|
||||
lh.insert(2, h_w);
|
||||
lh.insert(3, h_v);
|
||||
lh.insert(4, h_v);
|
||||
let mut la: HashMap<i64, bool> = HashMap::new();
|
||||
la.insert(2, true);
|
||||
la.insert(3, false);
|
||||
la.insert(4, false);
|
||||
let p = plan(&server, &local, &lh, &la);
|
||||
let mut new_count = 0;
|
||||
let mut unchanged = 0;
|
||||
let mut content_updated = 0;
|
||||
let mut active_only = 0;
|
||||
let mut vanished = 0;
|
||||
for a in p.actions {
|
||||
match a {
|
||||
Action::New { .. } => new_count += 1,
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::Unchanged,
|
||||
..
|
||||
} => unchanged += 1,
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::ContentUpdated,
|
||||
..
|
||||
} => content_updated += 1,
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::ActiveOnly,
|
||||
..
|
||||
} => active_only += 1,
|
||||
Action::Vanished { .. } => vanished += 1,
|
||||
}
|
||||
}
|
||||
assert_eq!(new_count, 1);
|
||||
assert_eq!(unchanged, 2);
|
||||
assert_eq!(content_updated, 0);
|
||||
assert_eq!(active_only, 0);
|
||||
assert_eq!(vanished, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_flags_content_update_when_hash_differs() {
|
||||
let h_v = hash(b"v");
|
||||
let h_w = hash(b"w");
|
||||
let server: Vec<(String, bool, [u8; 32])> = vec![("a".into(), false, h_w)];
|
||||
let mut local: HashMap<String, i64> = HashMap::new();
|
||||
local.insert("a".into(), 1);
|
||||
let mut lh = HashMap::new();
|
||||
lh.insert(1, h_v);
|
||||
let mut la = HashMap::new();
|
||||
la.insert(1, false);
|
||||
let p = plan(&server, &local, &lh, &la);
|
||||
assert!(matches!(
|
||||
p.actions[0],
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::ContentUpdated,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_flags_active_only_when_only_flag_differs() {
|
||||
let h = hash(b"same");
|
||||
let server: Vec<(String, bool, [u8; 32])> = vec![("a".into(), true, h)];
|
||||
let mut local: HashMap<String, i64> = HashMap::new();
|
||||
local.insert("a".into(), 1);
|
||||
let mut lh = HashMap::new();
|
||||
lh.insert(1, h);
|
||||
let mut la = HashMap::new();
|
||||
la.insert(1, false);
|
||||
let p = plan(&server, &local, &lh, &la);
|
||||
assert!(matches!(
|
||||
p.actions[0],
|
||||
Action::Present {
|
||||
outcome: PresentOutcome::ActiveOnly,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_new_inserts_script_and_id_mapping() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id = apply_new(&tx, sid, "vacation", b"require[\"x\"];").unwrap();
|
||||
tx.commit().unwrap();
|
||||
assert!(id > 0);
|
||||
let local = db::managesieve_ids::local_for(&c, sid, "vacation")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(local, id);
|
||||
let count: i64 = c
|
||||
.query_row("SELECT count(*) FROM sieve_scripts", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_content_update_swaps_blob_pointer() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id = apply_new(&tx, sid, "vacation", b"v1").unwrap();
|
||||
tx.commit().unwrap();
|
||||
let tx = c.transaction().unwrap();
|
||||
apply_content_update(&tx, id, b"v2").unwrap();
|
||||
tx.commit().unwrap();
|
||||
let blob: Vec<u8> = c
|
||||
.query_row(
|
||||
"SELECT b.data FROM sieve_scripts s JOIN blobs b ON b.id=s.blob_id WHERE s.id=?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(blob, b"v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_active_assignment_swaps_active_in_one_transaction() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id_a = apply_new(&tx, sid, "a", b"x").unwrap();
|
||||
let id_b = apply_new(&tx, sid, "b", b"y").unwrap();
|
||||
tx.commit().unwrap();
|
||||
apply_active_assignment(&mut c, Some(id_a)).unwrap();
|
||||
let cur: Option<i64> = c
|
||||
.query_row(
|
||||
"SELECT id FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap();
|
||||
assert_eq!(cur, Some(id_a));
|
||||
apply_active_assignment(&mut c, Some(id_b)).unwrap();
|
||||
let cur: Option<i64> = c
|
||||
.query_row(
|
||||
"SELECT id FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap();
|
||||
assert_eq!(cur, Some(id_b));
|
||||
apply_active_assignment(&mut c, None).unwrap();
|
||||
let cur: Option<i64> = c
|
||||
.query_row(
|
||||
"SELECT id FROM sieve_scripts WHERE is_active = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap();
|
||||
assert_eq!(cur, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_delete_clears_active_before_destroy() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id = apply_new(&tx, sid, "a", b"x").unwrap();
|
||||
tx.commit().unwrap();
|
||||
apply_active_assignment(&mut c, Some(id)).unwrap();
|
||||
let tx = c.transaction().unwrap();
|
||||
apply_delete(&tx, sid, "a", id).unwrap();
|
||||
tx.commit().unwrap();
|
||||
let count: i64 = c
|
||||
.query_row("SELECT count(*) FROM sieve_scripts", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
assert!(
|
||||
db::managesieve_ids::local_for(&c, sid, "a")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_new_dedups_identical_script_bytes_at_blob_level() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id_a = apply_new(&tx, sid, "a", b"require [\"fileinto\"];\n").unwrap();
|
||||
let id_b = apply_new(&tx, sid, "b", b"require [\"fileinto\"];\n").unwrap();
|
||||
tx.commit().unwrap();
|
||||
let blob_a: i64 = c
|
||||
.query_row(
|
||||
"SELECT blob_id FROM sieve_scripts WHERE id = ?1",
|
||||
params![id_a],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let blob_b: i64 = c
|
||||
.query_row(
|
||||
"SELECT blob_id FROM sieve_scripts WHERE id = ?1",
|
||||
params![id_b],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(blob_a, blob_b, "identical bytes must share a blob row");
|
||||
let blob_count: i64 = c
|
||||
.query_row("SELECT count(*) FROM blobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(blob_count, 1, "blob table must contain a single row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_content_update_orphans_old_blob_for_gc() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id = apply_new(&tx, sid, "a", b"v1").unwrap();
|
||||
tx.commit().unwrap();
|
||||
let tx = c.transaction().unwrap();
|
||||
apply_content_update(&tx, id, b"v2").unwrap();
|
||||
tx.commit().unwrap();
|
||||
let blob_count_before_gc: i64 = c
|
||||
.query_row("SELECT count(*) FROM blobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(blob_count_before_gc, 2, "old blob still present pre-GC");
|
||||
let tx = c.unchecked_transaction().unwrap();
|
||||
crate::db::blobs::gc_orphan_blobs(&tx).unwrap();
|
||||
tx.commit().unwrap();
|
||||
let blob_count_after_gc: i64 = c
|
||||
.query_row("SELECT count(*) FROM blobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(blob_count_after_gc, 1, "orphan blob reclaimed by GC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_unique_index_rejects_two_active_rows() {
|
||||
let (mut c, sid) = mem();
|
||||
let tx = c.transaction().unwrap();
|
||||
let id_a = apply_new(&tx, sid, "a", b"x").unwrap();
|
||||
let id_b = apply_new(&tx, sid, "b", b"y").unwrap();
|
||||
tx.commit().unwrap();
|
||||
c.execute(
|
||||
"UPDATE sieve_scripts SET is_active = 1 WHERE id = ?1",
|
||||
params![id_a],
|
||||
)
|
||||
.unwrap();
|
||||
let err = c
|
||||
.execute(
|
||||
"UPDATE sieve_scripts SET is_active = 1 WHERE id = ?1",
|
||||
params![id_b],
|
||||
)
|
||||
.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("UNIQUE") || msg.contains("unique"),
|
||||
"expected unique-constraint failure, got {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod calendar;
|
||||
pub mod contacts;
|
||||
pub mod coordinator;
|
||||
pub mod labels;
|
||||
pub mod mail;
|
||||
pub mod mbox;
|
||||
pub mod tree;
|
||||
pub mod walk;
|
||||
|
||||
pub use coordinator::{TakeoutImportConfig, run};
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::db::takeout_ids;
|
||||
use crate::error::Error;
|
||||
use crate::logging::Logger;
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::import_dav::calcard;
|
||||
|
||||
const DEFAULT_CALENDAR: &str = "Imported";
|
||||
|
||||
pub fn process_file(
|
||||
conn: &mut Connection,
|
||||
file_path: &Path,
|
||||
source_id: i64,
|
||||
calendar_cache: &mut HashMap<String, i64>,
|
||||
calendar_counts: &mut TypeCounts,
|
||||
event_counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let text = match std::fs::read_to_string(file_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logger.warn(&format!("{file_path:?}: read: {e}"));
|
||||
event_counts.failed += 1;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let entries = match calcard::ical_to_jscalendar_entries(&text) {
|
||||
Ok(v) => v,
|
||||
Err(calcard::CalcardError::NoEntries) => {
|
||||
logger.warn(&format!("{file_path:?}: iCalendar contains no events"));
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!("{file_path:?}: iCalendar parse: {e}"));
|
||||
event_counts.failed += 1;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let metadata = parse_calendar_metadata(&text);
|
||||
let container_name = metadata
|
||||
.x_wr_calname
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_CALENDAR.to_owned());
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
let calendar_local = ensure_calendar(
|
||||
&tx,
|
||||
source_id,
|
||||
&container_name,
|
||||
&metadata,
|
||||
calendar_cache,
|
||||
calendar_counts,
|
||||
)?;
|
||||
|
||||
for (ordinal, mut entry) in entries.into_iter().enumerate() {
|
||||
match insert_event(
|
||||
&tx,
|
||||
source_id,
|
||||
calendar_local,
|
||||
file_path,
|
||||
ordinal,
|
||||
&mut entry,
|
||||
event_counts,
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
logger.warn(&format!("{file_path:?}#{ordinal}: event insert: {e}"));
|
||||
event_counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct CalendarMetadata {
|
||||
x_wr_calname: Option<String>,
|
||||
x_wr_caldesc: Option<String>,
|
||||
x_wr_timezone: Option<String>,
|
||||
}
|
||||
|
||||
fn ensure_calendar(
|
||||
tx: &Transaction<'_>,
|
||||
source_id: i64,
|
||||
name: &str,
|
||||
metadata: &CalendarMetadata,
|
||||
cache: &mut HashMap<String, i64>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<i64, Error> {
|
||||
if let Some(&id) = cache.get(name) {
|
||||
return Ok(id);
|
||||
}
|
||||
if let Some(id) = takeout_ids::local_for(tx, source_id, takeout_ids::CALENDAR, name)? {
|
||||
cache.insert(name.to_owned(), id);
|
||||
return Ok(id);
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO calendars
|
||||
(name, description, time_zone, sort_order, is_subscribed, is_visible, is_default)
|
||||
VALUES (?1, ?2, ?3, 0, 1, 1, 0)",
|
||||
params![name, metadata.x_wr_caldesc, metadata.x_wr_timezone],
|
||||
)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
takeout_ids::insert(tx, source_id, takeout_ids::CALENDAR, name, id)?;
|
||||
cache.insert(name.to_owned(), id);
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn insert_event(
|
||||
tx: &Transaction<'_>,
|
||||
source_id: i64,
|
||||
calendar_local: i64,
|
||||
file_path: &Path,
|
||||
ordinal: usize,
|
||||
entry: &mut calcard::JsCalendarEntry,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let (is_draft, use_default_alerts, uid_field) =
|
||||
calcard::strip_extracted_fields_from_event(&mut entry.data);
|
||||
let uid = match uid_field
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
Some(u) => u.to_owned(),
|
||||
None => calcard::synthesise_uid(&format!("{}#{ordinal}", file_path.to_string_lossy())),
|
||||
};
|
||||
let data_type = entry.data_type.as_column();
|
||||
let data_json = entry.data.to_string();
|
||||
|
||||
let existing = takeout_ids::local_for(tx, source_id, takeout_ids::CALENDAR_EVENT, &uid)?;
|
||||
|
||||
if let Some(local) = existing {
|
||||
let calendar_ids =
|
||||
merge_container_id(tx, "calendar_events", "calendar_ids", local, calendar_local)?;
|
||||
let current: (String, i64, i64, String, String) = tx.query_row(
|
||||
"SELECT calendar_ids, is_draft, use_default_alerts, data, data_type
|
||||
FROM calendar_events WHERE id = ?1",
|
||||
params![local],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
||||
)?;
|
||||
let want = (
|
||||
calendar_ids.clone(),
|
||||
is_draft as i64,
|
||||
use_default_alerts as i64,
|
||||
data_json.clone(),
|
||||
data_type.to_owned(),
|
||||
);
|
||||
if current == want {
|
||||
counts.skipped += 1;
|
||||
return Ok(());
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE calendar_events SET calendar_ids = ?1, is_draft = ?2,
|
||||
use_default_alerts = ?3, data = ?4, data_type = ?5
|
||||
WHERE id = ?6",
|
||||
params![
|
||||
calendar_ids,
|
||||
is_draft as i64,
|
||||
use_default_alerts as i64,
|
||||
data_json,
|
||||
data_type,
|
||||
local,
|
||||
],
|
||||
)?;
|
||||
counts.fetched += 1;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let calendar_ids = format!("[{calendar_local}]");
|
||||
tx.execute(
|
||||
"INSERT INTO calendar_events
|
||||
(calendar_ids, is_draft, use_default_alerts, data, data_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
calendar_ids,
|
||||
is_draft as i64,
|
||||
use_default_alerts as i64,
|
||||
data_json,
|
||||
data_type,
|
||||
],
|
||||
)?;
|
||||
let local_id = tx.last_insert_rowid();
|
||||
takeout_ids::insert(tx, source_id, takeout_ids::CALENDAR_EVENT, &uid, local_id)?;
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_container_id(
|
||||
tx: &Transaction<'_>,
|
||||
table: &str,
|
||||
column: &str,
|
||||
local_id: i64,
|
||||
new_id: i64,
|
||||
) -> Result<String, Error> {
|
||||
let current: String = tx
|
||||
.query_row(
|
||||
&format!("SELECT {column} FROM {table} WHERE id = ?1"),
|
||||
params![local_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or_else(|| "[]".to_owned());
|
||||
let mut ids: Vec<i64> = match serde_json::from_str::<Value>(¤t) {
|
||||
Ok(Value::Array(arr)) => arr.into_iter().filter_map(|v| v.as_i64()).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if !ids.contains(&new_id) {
|
||||
ids.push(new_id);
|
||||
}
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
Ok(Value::Array(ids.into_iter().map(Value::from).collect()).to_string())
|
||||
}
|
||||
|
||||
fn parse_calendar_metadata(text: &str) -> CalendarMetadata {
|
||||
let mut out = CalendarMetadata::default();
|
||||
let mut inside_vevent = false;
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim_end_matches('\r');
|
||||
if line.starts_with("BEGIN:VEVENT")
|
||||
|| line.starts_with("BEGIN:VTODO")
|
||||
|| line.starts_with("BEGIN:VJOURNAL")
|
||||
{
|
||||
inside_vevent = true;
|
||||
continue;
|
||||
}
|
||||
if line.starts_with("END:VEVENT")
|
||||
|| line.starts_with("END:VTODO")
|
||||
|| line.starts_with("END:VJOURNAL")
|
||||
{
|
||||
inside_vevent = false;
|
||||
continue;
|
||||
}
|
||||
if inside_vevent {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("X-WR-CALNAME:") {
|
||||
out.x_wr_calname = Some(rest.trim().to_owned());
|
||||
} else if let Some(rest) = line.strip_prefix("X-WR-CALDESC:") {
|
||||
out.x_wr_caldesc = Some(rest.trim().to_owned());
|
||||
} else if let Some(rest) = line.strip_prefix("X-WR-TIMEZONE:") {
|
||||
out.x_wr_timezone = Some(rest.trim().to_owned());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_metadata_finds_xwr_fields() {
|
||||
let ical = "BEGIN:VCALENDAR\r\n\
|
||||
PRODID:-//Google Inc//Google Calendar 70.9054//EN\r\n\
|
||||
X-WR-CALNAME:My Calendar\r\n\
|
||||
X-WR-CALDESC:Some description\r\n\
|
||||
X-WR-TIMEZONE:Europe/Madrid\r\n\
|
||||
BEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTAMP:20250101T000000Z\r\n\
|
||||
END:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
let m = parse_calendar_metadata(ical);
|
||||
assert_eq!(m.x_wr_calname.as_deref(), Some("My Calendar"));
|
||||
assert_eq!(m.x_wr_caldesc.as_deref(), Some("Some description"));
|
||||
assert_eq!(m.x_wr_timezone.as_deref(), Some("Europe/Madrid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_metadata_ignores_xwr_inside_vevent() {
|
||||
let ical = "BEGIN:VCALENDAR\r\n\
|
||||
BEGIN:VEVENT\r\nUID:e1\r\nX-WR-CALNAME:NOT THIS\r\nEND:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n";
|
||||
let m = parse_calendar_metadata(ical);
|
||||
assert!(m.x_wr_calname.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_metadata_empty_when_missing() {
|
||||
let ical = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n";
|
||||
let m = parse_calendar_metadata(ical);
|
||||
assert!(m.x_wr_calname.is_none());
|
||||
assert!(m.x_wr_caldesc.is_none());
|
||||
assert!(m.x_wr_timezone.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_metadata_handles_lf_only_line_endings() {
|
||||
let ical = "BEGIN:VCALENDAR\nX-WR-CALNAME:Foo\nEND:VCALENDAR\n";
|
||||
let m = parse_calendar_metadata(ical);
|
||||
assert_eq!(m.x_wr_calname.as_deref(), Some("Foo"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{Connection, Transaction, params};
|
||||
|
||||
use crate::db::takeout_ids;
|
||||
use crate::error::Error;
|
||||
use crate::logging::Logger;
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::import_dav::calcard;
|
||||
|
||||
const DEFAULT_ADDRESS_BOOK: &str = "Imported";
|
||||
|
||||
pub fn process_file(
|
||||
conn: &mut Connection,
|
||||
file_path: &Path,
|
||||
source_id: i64,
|
||||
address_book_local: &mut Option<i64>,
|
||||
book_counts: &mut TypeCounts,
|
||||
card_counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let text = match std::fs::read_to_string(file_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logger.warn(&format!("{file_path:?}: read: {e}"));
|
||||
card_counts.failed += 1;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let chunks = split_vcards(&text);
|
||||
if chunks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
let book_local = match *address_book_local {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = ensure_default_address_book(&tx, source_id, book_counts)?;
|
||||
*address_book_local = Some(id);
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
for (ordinal, chunk) in chunks.into_iter().enumerate() {
|
||||
match insert_card(
|
||||
&tx,
|
||||
source_id,
|
||||
book_local,
|
||||
file_path,
|
||||
ordinal,
|
||||
&chunk,
|
||||
card_counts,
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
logger.warn(&format!("{file_path:?}#{ordinal}: vCard: {e}"));
|
||||
card_counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_default_address_book(
|
||||
tx: &Transaction<'_>,
|
||||
source_id: i64,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<i64, Error> {
|
||||
if let Some(id) = takeout_ids::local_for(
|
||||
tx,
|
||||
source_id,
|
||||
takeout_ids::ADDRESS_BOOK,
|
||||
DEFAULT_ADDRESS_BOOK,
|
||||
)? {
|
||||
return Ok(id);
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO address_books
|
||||
(name, description, sort_order, is_default, is_subscribed)
|
||||
VALUES (?1, NULL, 0, 0, 1)",
|
||||
params![DEFAULT_ADDRESS_BOOK],
|
||||
)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
takeout_ids::insert(
|
||||
tx,
|
||||
source_id,
|
||||
takeout_ids::ADDRESS_BOOK,
|
||||
DEFAULT_ADDRESS_BOOK,
|
||||
id,
|
||||
)?;
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn insert_card(
|
||||
tx: &Transaction<'_>,
|
||||
source_id: i64,
|
||||
book_local: i64,
|
||||
file_path: &Path,
|
||||
ordinal: usize,
|
||||
chunk: &str,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let synthetic_href = format!("{}#{ordinal}", file_path.to_string_lossy());
|
||||
let card = calcard::vcard_to_jscontact(chunk, &synthetic_href)
|
||||
.map_err(|e| Error::Partial(format!("vCard parse: {e}")))?;
|
||||
let data_json = card.data.to_string();
|
||||
let address_book_ids = format!("[{book_local}]");
|
||||
|
||||
let existing = takeout_ids::local_for(tx, source_id, takeout_ids::CONTACT_CARD, &card.uid)?;
|
||||
|
||||
if let Some(local) = existing {
|
||||
let current: (String, String, String) = tx.query_row(
|
||||
"SELECT uid, address_book_ids, data FROM contact_cards WHERE id = ?1",
|
||||
params![local],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)?;
|
||||
let want = (
|
||||
card.uid.clone(),
|
||||
address_book_ids.clone(),
|
||||
data_json.clone(),
|
||||
);
|
||||
if current == want {
|
||||
counts.skipped += 1;
|
||||
return Ok(());
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE contact_cards SET uid = ?1, address_book_ids = ?2, data = ?3
|
||||
WHERE id = ?4",
|
||||
params![card.uid, address_book_ids, data_json, local],
|
||||
)?;
|
||||
counts.fetched += 1;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO contact_cards (uid, address_book_ids, data) VALUES (?1, ?2, ?3)",
|
||||
params![card.uid, address_book_ids, data_json],
|
||||
)?;
|
||||
let local_id = tx.last_insert_rowid();
|
||||
takeout_ids::insert(
|
||||
tx,
|
||||
source_id,
|
||||
takeout_ids::CONTACT_CARD,
|
||||
&card.uid,
|
||||
local_id,
|
||||
)?;
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_vcards(text: &str) -> Vec<String> {
|
||||
let mut chunks: Vec<String> = Vec::new();
|
||||
let mut current: Option<String> = None;
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim_end_matches('\r');
|
||||
let bytes = line.as_bytes();
|
||||
if starts_with_ignore_ascii_case(bytes, b"BEGIN:VCARD") {
|
||||
current = Some(String::new());
|
||||
}
|
||||
if let Some(buf) = current.as_mut() {
|
||||
buf.push_str(line);
|
||||
buf.push('\n');
|
||||
}
|
||||
if starts_with_ignore_ascii_case(bytes, b"END:VCARD")
|
||||
&& let Some(buf) = current.take()
|
||||
{
|
||||
chunks.push(buf);
|
||||
}
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
fn starts_with_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack.len() >= needle.len() && haystack[..needle.len()].eq_ignore_ascii_case(needle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn splits_single_card() {
|
||||
let text = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nEND:VCARD\r\n";
|
||||
let chunks = split_vcards(text);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert!(chunks[0].contains("FN:A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_multiple_cards() {
|
||||
let text = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nEND:VCARD\r\n\
|
||||
BEGIN:VCARD\r\nVERSION:3.0\r\nFN:B\r\nEND:VCARD\r\n\
|
||||
BEGIN:VCARD\r\nVERSION:3.0\r\nFN:C\r\nEND:VCARD\r\n";
|
||||
let chunks = split_vcards(text);
|
||||
assert_eq!(chunks.len(), 3);
|
||||
assert!(chunks[0].contains("FN:A"));
|
||||
assert!(chunks[1].contains("FN:B"));
|
||||
assert!(chunks[2].contains("FN:C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_lines_outside_any_card() {
|
||||
let text = "Some preamble garbage\r\n\
|
||||
BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nEND:VCARD\r\n\
|
||||
garbage between cards\r\n\
|
||||
BEGIN:VCARD\r\nVERSION:3.0\r\nFN:B\r\nEND:VCARD\r\n\
|
||||
trailing garbage\r\n";
|
||||
let chunks = split_vcards(text);
|
||||
assert_eq!(chunks.len(), 2);
|
||||
assert!(!chunks[0].contains("garbage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_folded_continuation_lines_inside_a_card() {
|
||||
let text = "BEGIN:VCARD\r\nVERSION:3.0\r\n\
|
||||
ADR:;;1 Infinite Loop\\nCupertino\\, CA 95014\\nUnited States\r\n \
|
||||
;;;;;\r\nEND:VCARD\r\n";
|
||||
let chunks = split_vcards(text);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert!(chunks[0].contains("ADR:"));
|
||||
assert!(chunks[0].contains(";;;;;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_lf_only_line_endings() {
|
||||
let text = "BEGIN:VCARD\nVERSION:3.0\nFN:LF\nEND:VCARD\n";
|
||||
let chunks = split_vcards(text);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert!(chunks[0].contains("FN:LF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_yields_no_chunks() {
|
||||
assert!(split_vcards("").is_empty());
|
||||
assert!(split_vcards("not a vcard\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_card_is_dropped() {
|
||||
let text = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\n";
|
||||
let chunks = split_vcards(text);
|
||||
assert!(chunks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_boundary_match() {
|
||||
let text = "begin:vcard\r\nFN:CaseTest\r\nend:vcard\r\n";
|
||||
let chunks = split_vcards(text);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert!(chunks[0].contains("FN:CaseTest"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use url::Url;
|
||||
|
||||
use crate::db;
|
||||
use crate::db::sources::SourceKey;
|
||||
use crate::db::takeout_ids;
|
||||
use crate::error::Error;
|
||||
use crate::logging::{LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
|
||||
use crate::sync::{CommonConfig, Summary, TypeCounts};
|
||||
|
||||
use super::calendar;
|
||||
use super::contacts;
|
||||
use super::labels::MappingOptions;
|
||||
use super::mail;
|
||||
use super::walk::{self, FileKind};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TakeoutImportConfig {
|
||||
pub takeout_root: PathBuf,
|
||||
pub allow_source_change: bool,
|
||||
pub automap: bool,
|
||||
}
|
||||
|
||||
pub fn run(common: CommonConfig, config: TakeoutImportConfig) -> Result<Summary, Error> {
|
||||
let logger = common.logger;
|
||||
if common.threads > 1 && logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!("takeout importer is single-threaded; --threads value will be ignored");
|
||||
}
|
||||
|
||||
let canonical = std::fs::canonicalize(&config.takeout_root)
|
||||
.map_err(|e| Error::Usage(format!("--path {:?}: {e}", config.takeout_root)))?;
|
||||
if !canonical.is_dir() {
|
||||
return Err(Error::Usage(format!("not a directory: {canonical:?}")));
|
||||
}
|
||||
|
||||
let walk_result = walk::walk_with_logger(&canonical, logger)
|
||||
.map_err(|e| Error::Usage(format!("walking {canonical:?}: {e}")))?;
|
||||
if walk_result.is_empty() {
|
||||
return Err(Error::Usage(format!(
|
||||
"{canonical:?}: no .mbox / .ics / .vcf files found under this path"
|
||||
)));
|
||||
}
|
||||
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
let mbox_n = walk_result.by_kind(FileKind::Mbox).count();
|
||||
let ics_n = walk_result.by_kind(FileKind::Ics).count();
|
||||
let vcf_n = walk_result.by_kind(FileKind::Vcf).count();
|
||||
eprintln!(
|
||||
"takeout discovery: {} .mbox, {} .ics, {} .vcf (io failures: {}, symlink cycles: {})",
|
||||
mbox_n, ics_n, vcf_n, walk_result.io_failures, walk_result.symlink_cycles
|
||||
);
|
||||
}
|
||||
|
||||
let mut conn = db::init::open(&common.archive)?;
|
||||
let session_url = file_url_for(&canonical)?;
|
||||
let account_id = canonical.to_string_lossy().into_owned();
|
||||
let account_name = canonical
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(str::to_owned);
|
||||
|
||||
if let Some((existing_url, existing_account)) =
|
||||
db::sources::conflicting_source(&conn, "takeout", &session_url, &account_id)?
|
||||
&& !config.allow_source_change
|
||||
{
|
||||
return Err(Error::SourceChange(format!(
|
||||
"archive already records takeout source {existing_url} / {existing_account}; \
|
||||
re-run with --allow-source-change or use a fresh archive"
|
||||
)));
|
||||
}
|
||||
|
||||
let source_key = SourceKey {
|
||||
kind: "takeout".to_owned(),
|
||||
session_url: session_url.clone(),
|
||||
account_id: account_id.clone(),
|
||||
};
|
||||
let source_id = if common.dry_run {
|
||||
db::sources::find_source(&conn, &source_key)?.unwrap_or(-1)
|
||||
} else {
|
||||
db::sources::upsert_source(&conn, &source_key, account_name.as_deref(), "")?
|
||||
};
|
||||
|
||||
if common.dry_run {
|
||||
return Ok(dry_run_summary(&conn, source_id, &walk_result, logger));
|
||||
}
|
||||
|
||||
let options = MappingOptions {
|
||||
automap: config.automap,
|
||||
};
|
||||
|
||||
let mut mailbox_counts = TypeCounts::default();
|
||||
let mut email_counts = TypeCounts::default();
|
||||
let mut calendar_counts = TypeCounts::default();
|
||||
let mut calendar_event_counts = TypeCounts::default();
|
||||
let mut book_counts = TypeCounts::default();
|
||||
let mut card_counts = TypeCounts::default();
|
||||
|
||||
let mut mailbox_cache: HashMap<String, i64> =
|
||||
takeout_ids::all_for_type(&conn, source_id, takeout_ids::MAILBOX)?;
|
||||
|
||||
process_mbox_files(
|
||||
&mut conn,
|
||||
source_id,
|
||||
&walk_result,
|
||||
options,
|
||||
&mut mailbox_cache,
|
||||
&mut mailbox_counts,
|
||||
&mut email_counts,
|
||||
logger,
|
||||
)?;
|
||||
|
||||
process_ics_files(
|
||||
&mut conn,
|
||||
source_id,
|
||||
&walk_result,
|
||||
&mut calendar_counts,
|
||||
&mut calendar_event_counts,
|
||||
logger,
|
||||
)?;
|
||||
|
||||
process_vcf_files(
|
||||
&mut conn,
|
||||
source_id,
|
||||
&walk_result,
|
||||
&mut book_counts,
|
||||
&mut card_counts,
|
||||
logger,
|
||||
)?;
|
||||
|
||||
let no_failures = mailbox_counts.failed == 0
|
||||
&& email_counts.failed == 0
|
||||
&& calendar_counts.failed == 0
|
||||
&& calendar_event_counts.failed == 0
|
||||
&& book_counts.failed == 0
|
||||
&& card_counts.failed == 0;
|
||||
if no_failures {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
db::blobs::gc_orphan_blobs(&tx)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!(
|
||||
"takeout: mailbox c={} f={} | email c={} u={} f={} | calendar c={} f={} | \
|
||||
event c={} u={} f={} | addressbook c={} | card c={} u={} f={}",
|
||||
mailbox_counts.created,
|
||||
mailbox_counts.failed,
|
||||
email_counts.created,
|
||||
email_counts.fetched.saturating_sub(email_counts.created),
|
||||
email_counts.failed,
|
||||
calendar_counts.created,
|
||||
calendar_counts.failed,
|
||||
calendar_event_counts.created,
|
||||
calendar_event_counts
|
||||
.fetched
|
||||
.saturating_sub(calendar_event_counts.created),
|
||||
calendar_event_counts.failed,
|
||||
book_counts.created,
|
||||
card_counts.created,
|
||||
card_counts.fetched.saturating_sub(card_counts.created),
|
||||
card_counts.failed,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Summary {
|
||||
per_type: vec![
|
||||
("mailbox", mailbox_counts),
|
||||
("email", email_counts),
|
||||
("calendar", calendar_counts),
|
||||
("calendarevent", calendar_event_counts),
|
||||
("addressbook", book_counts),
|
||||
("contactcard", card_counts),
|
||||
],
|
||||
retries_observed: 0,
|
||||
retry_after_sleeps: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_mbox_files(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
walk_result: &walk::WalkResult,
|
||||
options: MappingOptions,
|
||||
mailbox_cache: &mut HashMap<String, i64>,
|
||||
mailbox_counts: &mut TypeCounts,
|
||||
email_counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
for file in walk_result.by_kind(FileKind::Mbox) {
|
||||
let fallback = mailbox_name_from_filename(&file.path);
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"mbox: processing {:?} (fallback mailbox: {:?})",
|
||||
file.path, fallback
|
||||
);
|
||||
}
|
||||
let ctx = mail::InsertContext {
|
||||
source_id,
|
||||
fallback_mailbox: &fallback,
|
||||
options,
|
||||
mailbox_cache,
|
||||
};
|
||||
match mail::process_file(conn, &file.path, ctx, mailbox_counts, email_counts, logger) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
logger.warn(&format!("mbox {:?}: {e}", file.path));
|
||||
email_counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_ics_files(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
walk_result: &walk::WalkResult,
|
||||
calendar_counts: &mut TypeCounts,
|
||||
event_counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let mut cache: HashMap<String, i64> =
|
||||
takeout_ids::all_for_type(conn, source_id, takeout_ids::CALENDAR)?;
|
||||
for file in walk_result.by_kind(FileKind::Ics) {
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!("ics: processing {:?}", file.path);
|
||||
}
|
||||
if let Err(e) = calendar::process_file(
|
||||
conn,
|
||||
&file.path,
|
||||
source_id,
|
||||
&mut cache,
|
||||
calendar_counts,
|
||||
event_counts,
|
||||
logger,
|
||||
) {
|
||||
logger.warn(&format!("ics {:?}: {e}", file.path));
|
||||
event_counts.failed += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_vcf_files(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
walk_result: &walk::WalkResult,
|
||||
book_counts: &mut TypeCounts,
|
||||
card_counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let mut book_local: Option<i64> =
|
||||
takeout_ids::local_for(conn, source_id, takeout_ids::ADDRESS_BOOK, "Imported")?;
|
||||
for file in walk_result.by_kind(FileKind::Vcf) {
|
||||
if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!("vcf: processing {:?}", file.path);
|
||||
}
|
||||
if let Err(e) = contacts::process_file(
|
||||
conn,
|
||||
&file.path,
|
||||
source_id,
|
||||
&mut book_local,
|
||||
book_counts,
|
||||
card_counts,
|
||||
logger,
|
||||
) {
|
||||
logger.warn(&format!("vcf {:?}: {e}", file.path));
|
||||
card_counts.failed += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mailbox_name_from_filename(path: &Path) -> String {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| "Imported".to_owned())
|
||||
}
|
||||
|
||||
fn file_url_for(p: &Path) -> Result<String, Error> {
|
||||
Url::from_file_path(p)
|
||||
.map(|u| u.to_string())
|
||||
.map_err(|_| Error::Usage(format!("cannot encode takeout path as file:// URL: {p:?}")))
|
||||
}
|
||||
|
||||
fn dry_run_summary(
|
||||
conn: &Connection,
|
||||
source_id: i64,
|
||||
walk_result: &walk::WalkResult,
|
||||
logger: Logger,
|
||||
) -> Summary {
|
||||
let mbox_n = walk_result.by_kind(FileKind::Mbox).count();
|
||||
let ics_n = walk_result.by_kind(FileKind::Ics).count();
|
||||
let vcf_n = walk_result.by_kind(FileKind::Vcf).count();
|
||||
let local_emails = if source_id < 0 {
|
||||
0
|
||||
} else {
|
||||
count_rows(conn, source_id, takeout_ids::EMAIL)
|
||||
};
|
||||
let local_events = if source_id < 0 {
|
||||
0
|
||||
} else {
|
||||
count_rows(conn, source_id, takeout_ids::CALENDAR_EVENT)
|
||||
};
|
||||
let local_cards = if source_id < 0 {
|
||||
0
|
||||
} else {
|
||||
count_rows(conn, source_id, takeout_ids::CONTACT_CARD)
|
||||
};
|
||||
if logger.enabled(LEVEL_DEFAULT) {
|
||||
eprintln!("dry-run: mbox files={mbox_n} ics files={ics_n} vcf files={vcf_n}");
|
||||
eprintln!(
|
||||
" existing in archive: emails={local_emails} events={local_events} cards={local_cards}"
|
||||
);
|
||||
}
|
||||
Summary {
|
||||
per_type: vec![
|
||||
("mailbox", TypeCounts::default()),
|
||||
("email", TypeCounts::default()),
|
||||
("calendar", TypeCounts::default()),
|
||||
("calendarevent", TypeCounts::default()),
|
||||
("addressbook", TypeCounts::default()),
|
||||
("contactcard", TypeCounts::default()),
|
||||
],
|
||||
retries_observed: 0,
|
||||
retry_after_sleeps: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn count_rows(conn: &Connection, source_id: i64, type_name: &str) -> u64 {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM sync_id_takeout WHERE source_id = ?1 AND type_name = ?2",
|
||||
params![source_id, type_name],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap_or(0) as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_url_encodes_unicode_path() {
|
||||
let p = Path::new("/tmp/Año");
|
||||
let u = file_url_for(p).unwrap();
|
||||
assert!(u.starts_with("file://"));
|
||||
assert!(u.contains("A%C3%B1o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_url_rejects_relative_path() {
|
||||
let err = file_url_for(Path::new("./relative")).unwrap_err();
|
||||
assert!(matches!(err, Error::Usage(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mailbox_name_from_filename_strips_extension() {
|
||||
let p = Path::new("/some/dir/Github.mbox");
|
||||
assert_eq!(mailbox_name_from_filename(p), "Github");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mailbox_name_from_filename_handles_dotted_basename() {
|
||||
let p = Path::new("/x/All mail Including Spam and Trash.mbox");
|
||||
assert_eq!(
|
||||
mailbox_name_from_filename(p),
|
||||
"All mail Including Spam and Trash"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LabelClassification {
|
||||
pub mailboxes: Vec<String>,
|
||||
pub keywords: BTreeSet<String>,
|
||||
pub had_explicit_seen_signal: bool,
|
||||
pub had_unread_signal: bool,
|
||||
pub opened_won_over_unread: bool,
|
||||
}
|
||||
|
||||
impl LabelClassification {
|
||||
pub fn keywords_sorted(&self) -> Vec<String> {
|
||||
self.keywords.iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.mailboxes.is_empty() && self.keywords.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MappingOptions {
|
||||
pub automap: bool,
|
||||
}
|
||||
|
||||
impl Default for MappingOptions {
|
||||
fn default() -> Self {
|
||||
Self { automap: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MailboxAssignment {
|
||||
pub canonical_path: String,
|
||||
pub role: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub fn parse_header(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn classify(tokens: &[String]) -> LabelClassification {
|
||||
let mut mailboxes: Vec<String> = Vec::new();
|
||||
let mut keywords: BTreeSet<String> = BTreeSet::new();
|
||||
let mut had_opened = false;
|
||||
let mut had_unread = false;
|
||||
|
||||
for tok in tokens {
|
||||
if is_category_token(tok) {
|
||||
continue;
|
||||
}
|
||||
match tok.as_str() {
|
||||
"Starred" => {
|
||||
keywords.insert("$flagged".to_owned());
|
||||
}
|
||||
"Important" => {
|
||||
keywords.insert("$important".to_owned());
|
||||
}
|
||||
"Opened" => {
|
||||
had_opened = true;
|
||||
keywords.insert("$seen".to_owned());
|
||||
}
|
||||
"Unread" => {
|
||||
had_unread = true;
|
||||
}
|
||||
other => {
|
||||
let canonical = canonical_label(other);
|
||||
if !mailboxes.iter().any(|m| m == &canonical) {
|
||||
mailboxes.push(canonical);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let opened_won_over_unread = had_opened && had_unread;
|
||||
LabelClassification {
|
||||
mailboxes,
|
||||
keywords,
|
||||
had_explicit_seen_signal: had_opened,
|
||||
had_unread_signal: had_unread,
|
||||
opened_won_over_unread,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn role_for_mailbox(canonical_path: &str, opts: MappingOptions) -> Option<&'static str> {
|
||||
if !opts.automap {
|
||||
return None;
|
||||
}
|
||||
if canonical_path.contains('/') {
|
||||
return None;
|
||||
}
|
||||
match canonical_path {
|
||||
"Inbox" => Some("inbox"),
|
||||
"Sent" => Some("sent"),
|
||||
"Drafts" => Some("drafts"),
|
||||
"Trash" => Some("trash"),
|
||||
"Spam" => Some("junk"),
|
||||
"Archive" => Some("archive"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assignments_for(
|
||||
classification: &LabelClassification,
|
||||
opts: MappingOptions,
|
||||
) -> Vec<MailboxAssignment> {
|
||||
classification
|
||||
.mailboxes
|
||||
.iter()
|
||||
.map(|p| MailboxAssignment {
|
||||
canonical_path: p.clone(),
|
||||
role: role_for_mailbox(p, opts),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_category_token(t: &str) -> bool {
|
||||
matches!(
|
||||
t,
|
||||
"Category Personal"
|
||||
| "Category Promotions"
|
||||
| "Category Social"
|
||||
| "Category Updates"
|
||||
| "Category Forums"
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_label(raw: &str) -> String {
|
||||
match raw {
|
||||
"Archived" => "Archive".to_owned(),
|
||||
other => other.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn classify_str(s: &str) -> LabelClassification {
|
||||
classify(&parse_header(s))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_header_splits_on_comma_and_trims() {
|
||||
let v = parse_header("Inbox, Opened ,Starred,,Important");
|
||||
assert_eq!(v, vec!["Inbox", "Opened", "Starred", "Important"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_header_empty_yields_empty() {
|
||||
assert!(parse_header("").is_empty());
|
||||
assert!(parse_header(",, ,, ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inbox_token_becomes_inbox_mailbox_with_role() {
|
||||
let c = classify_str("Inbox");
|
||||
assert_eq!(c.mailboxes, vec!["Inbox".to_owned()]);
|
||||
let a = assignments_for(&c, MappingOptions::default());
|
||||
assert_eq!(a[0].role, Some("inbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_drafts_trash_spam_archive_map_to_roles() {
|
||||
for (token, role) in [
|
||||
("Sent", "sent"),
|
||||
("Drafts", "drafts"),
|
||||
("Trash", "trash"),
|
||||
("Spam", "junk"),
|
||||
("Archived", "archive"),
|
||||
] {
|
||||
let c = classify_str(token);
|
||||
let a = assignments_for(&c, MappingOptions::default());
|
||||
assert_eq!(a[0].role, Some(role), "token {token}");
|
||||
}
|
||||
let c = classify_str("Archived");
|
||||
assert_eq!(c.mailboxes, vec!["Archive".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starred_important_become_keywords_not_mailboxes() {
|
||||
let c = classify_str("Starred,Important");
|
||||
assert!(c.mailboxes.is_empty());
|
||||
assert!(c.keywords.contains("$flagged"));
|
||||
assert!(c.keywords.contains("$important"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_yields_seen_keyword() {
|
||||
let c = classify_str("Inbox,Opened");
|
||||
assert!(c.keywords.contains("$seen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_alone_means_no_seen() {
|
||||
let c = classify_str("Inbox,Unread");
|
||||
assert!(!c.keywords.contains("$seen"));
|
||||
assert!(c.had_unread_signal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_plus_unread_marks_collision_and_keeps_seen() {
|
||||
let c = classify_str("Inbox,Opened,Unread");
|
||||
assert!(c.keywords.contains("$seen"));
|
||||
assert!(c.opened_won_over_unread);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_tokens_are_dropped_silently() {
|
||||
let c = classify_str("Inbox,Category Promotions,Category Updates,Opened");
|
||||
assert_eq!(c.mailboxes, vec!["Inbox".to_owned()]);
|
||||
assert!(c.keywords.contains("$seen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_labels_preserve_full_path() {
|
||||
let c = classify_str("Inbox,Label_001/Label_002_under_Label_001");
|
||||
assert!(
|
||||
c.mailboxes
|
||||
.contains(&"Label_001/Label_002_under_Label_001".to_owned())
|
||||
);
|
||||
let a = assignments_for(&c, MappingOptions::default());
|
||||
let nested = a
|
||||
.iter()
|
||||
.find(|m| m.canonical_path == "Label_001/Label_002_under_Label_001")
|
||||
.unwrap();
|
||||
assert_eq!(nested.role, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_labels_get_no_role() {
|
||||
let c = classify_str("Inbox,Github,Newsletter");
|
||||
let a = assignments_for(&c, MappingOptions::default());
|
||||
let github = a.iter().find(|m| m.canonical_path == "Github").unwrap();
|
||||
assert_eq!(github.role, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realistic_fixture_label_sets_classify_as_expected() {
|
||||
let c = classify_str("Archived,Important,Opened,Category Social,Github");
|
||||
assert!(c.mailboxes.contains(&"Archive".to_owned()));
|
||||
assert!(c.mailboxes.contains(&"Github".to_owned()));
|
||||
assert!(c.keywords.contains("$important"));
|
||||
assert!(c.keywords.contains("$seen"));
|
||||
assert!(!c.mailboxes.iter().any(|m| m.contains("Category")));
|
||||
|
||||
let c = classify_str("Important,Trash,Category Social,Unread,Github");
|
||||
assert!(c.mailboxes.contains(&"Trash".to_owned()));
|
||||
assert!(c.mailboxes.contains(&"Github".to_owned()));
|
||||
assert!(c.keywords.contains("$important"));
|
||||
assert!(!c.keywords.contains("$seen"));
|
||||
assert!(c.had_unread_signal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noautomap_suppresses_roles_but_keeps_names() {
|
||||
let c = classify_str("Inbox,Sent,Github");
|
||||
let a = assignments_for(&c, MappingOptions { automap: false });
|
||||
assert!(a.iter().all(|m| m.role.is_none()));
|
||||
let names: Vec<&str> = a.iter().map(|m| m.canonical_path.as_str()).collect();
|
||||
assert!(names.contains(&"Inbox"));
|
||||
assert!(names.contains(&"Sent"));
|
||||
assert!(names.contains(&"Github"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keywords_sorted_returns_deterministic_order() {
|
||||
let c = classify_str("Starred,Important,Opened");
|
||||
let k = c.keywords_sorted();
|
||||
assert_eq!(
|
||||
k,
|
||||
vec![
|
||||
"$flagged".to_owned(),
|
||||
"$important".to_owned(),
|
||||
"$seen".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_mailbox_token_dedups() {
|
||||
let c = classify_str("Github,Github,Inbox");
|
||||
let count = c
|
||||
.mailboxes
|
||||
.iter()
|
||||
.filter(|m| m.as_str() == "Github")
|
||||
.count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::db;
|
||||
use crate::db::takeout_ids;
|
||||
use crate::error::Error;
|
||||
use crate::logging::{LEVEL_PROGRESS, Logger};
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::emailmeta::email_meta_from_blob;
|
||||
use crate::sync::keys::index_to_json;
|
||||
|
||||
use super::labels::{self, MappingOptions};
|
||||
use super::mbox::{Message, MessageIterator};
|
||||
|
||||
pub struct InsertContext<'a> {
|
||||
pub source_id: i64,
|
||||
pub fallback_mailbox: &'a str,
|
||||
pub options: MappingOptions,
|
||||
pub mailbox_cache: &'a mut HashMap<String, i64>,
|
||||
}
|
||||
|
||||
pub fn process_file(
|
||||
conn: &mut Connection,
|
||||
file_path: &Path,
|
||||
ctx: InsertContext<'_>,
|
||||
mailbox_counts: &mut TypeCounts,
|
||||
email_counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let file =
|
||||
File::open(file_path).map_err(|e| Error::Partial(format!("open {file_path:?}: {e}")))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut iter = MessageIterator::new(reader);
|
||||
|
||||
let InsertContext {
|
||||
source_id,
|
||||
fallback_mailbox,
|
||||
options,
|
||||
mailbox_cache,
|
||||
} = ctx;
|
||||
let tx = conn.transaction()?;
|
||||
let mut commit_pending: u64 = 0;
|
||||
|
||||
loop {
|
||||
match iter.next() {
|
||||
None => break,
|
||||
Some(Err(e)) => {
|
||||
logger.warn(&format!("{file_path:?}: mbox io: {e}"));
|
||||
email_counts.failed += 1;
|
||||
break;
|
||||
}
|
||||
Some(Ok(msg)) => {
|
||||
match process_message(
|
||||
&tx,
|
||||
source_id,
|
||||
fallback_mailbox,
|
||||
options,
|
||||
&msg,
|
||||
mailbox_cache,
|
||||
mailbox_counts,
|
||||
email_counts,
|
||||
logger,
|
||||
) {
|
||||
Ok(()) => {
|
||||
commit_pending += 1;
|
||||
if commit_pending.is_multiple_of(PROGRESS_TICK)
|
||||
&& logger.enabled(LEVEL_PROGRESS)
|
||||
{
|
||||
eprintln!("{file_path:?}: processed {commit_pending} messages");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logger.warn(&format!("{file_path:?}: message: {e}"));
|
||||
email_counts.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const PROGRESS_TICK: u64 = 256;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_message(
|
||||
tx: &Transaction<'_>,
|
||||
source_id: i64,
|
||||
fallback_mailbox: &str,
|
||||
options: MappingOptions,
|
||||
msg: &Message,
|
||||
cache: &mut HashMap<String, i64>,
|
||||
mailbox_counts: &mut TypeCounts,
|
||||
email_counts: &mut TypeCounts,
|
||||
logger: Logger,
|
||||
) -> Result<(), Error> {
|
||||
let bytes = msg.contents();
|
||||
let msg_hash = blake3::hash(bytes).to_hex().to_string();
|
||||
|
||||
let raw_header = extract_x_gmail_labels(bytes);
|
||||
let had_labels_header = raw_header.is_some();
|
||||
let tokens = match &raw_header {
|
||||
Some(s) => labels::parse_header(s),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut classification = labels::classify(&tokens);
|
||||
if classification.mailboxes.is_empty() {
|
||||
let fallback = fallback_mailbox.trim();
|
||||
if fallback.is_empty() {
|
||||
classification.mailboxes.push("Imported".to_owned());
|
||||
} else {
|
||||
classification.mailboxes.push(fallback.to_owned());
|
||||
}
|
||||
if had_labels_header {
|
||||
logger.warn(&format!(
|
||||
"message {msg_hash}: X-Gmail-Labels present but empty after parse; \
|
||||
placing in fallback mailbox {:?}",
|
||||
classification.mailboxes[0]
|
||||
));
|
||||
} else {
|
||||
logger.warn(&format!(
|
||||
"message {msg_hash}: no X-Gmail-Labels header; placing in fallback mailbox {:?}",
|
||||
classification.mailboxes[0]
|
||||
));
|
||||
}
|
||||
}
|
||||
if classification.opened_won_over_unread {
|
||||
logger.warn(&format!(
|
||||
"message {msg_hash}: X-Gmail-Labels carried both 'Opened' and 'Unread'; \
|
||||
treating as $seen (Opened wins)"
|
||||
));
|
||||
}
|
||||
|
||||
let mut mailbox_ids: Vec<i64> = Vec::with_capacity(classification.mailboxes.len());
|
||||
for path in &classification.mailboxes {
|
||||
let id = ensure_mailbox(tx, source_id, path, options, cache, mailbox_counts)?;
|
||||
mailbox_ids.push(id);
|
||||
}
|
||||
mailbox_ids.sort_unstable();
|
||||
mailbox_ids.dedup();
|
||||
|
||||
let keywords_json = json_string_array(&classification.keywords_sorted());
|
||||
let mailbox_ids_json = json_int_array(&mailbox_ids);
|
||||
|
||||
if let Some(local_id) = takeout_ids::local_for(tx, source_id, takeout_ids::EMAIL, &msg_hash)? {
|
||||
let current: (String, String) = tx
|
||||
.query_row(
|
||||
"SELECT mailbox_ids, keywords FROM emails WHERE id = ?1",
|
||||
params![local_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or_default();
|
||||
if current.0 != mailbox_ids_json || current.1 != keywords_json {
|
||||
tx.execute(
|
||||
"UPDATE emails SET mailbox_ids = ?1, keywords = ?2 WHERE id = ?3",
|
||||
params![mailbox_ids_json, keywords_json, local_id],
|
||||
)?;
|
||||
email_counts.fetched += 1;
|
||||
} else {
|
||||
email_counts.skipped += 1;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let blob_id = db::blobs::intern_blob(tx, bytes)?;
|
||||
let (index, date_rfc3339) = email_meta_from_blob(bytes);
|
||||
let message_match = index_to_json(&index);
|
||||
let received_at = pick_received_at(msg.internal_date(), date_rfc3339.as_deref());
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords, message_match)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
blob_id,
|
||||
received_at,
|
||||
mailbox_ids_json,
|
||||
keywords_json,
|
||||
message_match
|
||||
],
|
||||
)?;
|
||||
let local_id = tx.last_insert_rowid();
|
||||
takeout_ids::insert(tx, source_id, takeout_ids::EMAIL, &msg_hash, local_id)?;
|
||||
email_counts.created += 1;
|
||||
email_counts.fetched += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_mailbox(
|
||||
tx: &Transaction<'_>,
|
||||
source_id: i64,
|
||||
path: &str,
|
||||
options: MappingOptions,
|
||||
cache: &mut HashMap<String, i64>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<i64, Error> {
|
||||
if let Some(&id) = cache.get(path) {
|
||||
return Ok(id);
|
||||
}
|
||||
if let Some(id) = takeout_ids::local_for(tx, source_id, takeout_ids::MAILBOX, path)? {
|
||||
cache.insert(path.to_owned(), id);
|
||||
return Ok(id);
|
||||
}
|
||||
let (parent_id, leaf) = match path.rsplit_once('/') {
|
||||
Some((parent, leaf)) => (
|
||||
Some(ensure_mailbox(
|
||||
tx, source_id, parent, options, cache, counts,
|
||||
)?),
|
||||
leaf,
|
||||
),
|
||||
None => (None, path),
|
||||
};
|
||||
let role = labels::role_for_mailbox(path, options);
|
||||
tx.execute(
|
||||
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
|
||||
VALUES (?1, ?2, ?3, 0, 1)",
|
||||
params![leaf, parent_id, role],
|
||||
)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
takeout_ids::insert(tx, source_id, takeout_ids::MAILBOX, path, id)?;
|
||||
cache.insert(path.to_owned(), id);
|
||||
counts.created += 1;
|
||||
counts.fetched += 1;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn json_string_array(values: &[String]) -> String {
|
||||
Value::Array(values.iter().map(|s| Value::String(s.clone())).collect()).to_string()
|
||||
}
|
||||
|
||||
fn json_int_array(values: &[i64]) -> String {
|
||||
Value::Array(values.iter().map(|&i| Value::from(i)).collect()).to_string()
|
||||
}
|
||||
|
||||
fn pick_received_at(internal_date: u64, date_rfc3339: Option<&str>) -> String {
|
||||
if internal_date > 0 {
|
||||
return format_unix_rfc3339(internal_date);
|
||||
}
|
||||
if let Some(d) = date_rfc3339 {
|
||||
return d.to_owned();
|
||||
}
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
|
||||
}
|
||||
|
||||
fn format_unix_rfc3339(secs: u64) -> String {
|
||||
let when = UNIX_EPOCH + Duration::from_secs(secs);
|
||||
OffsetDateTime::from(when)
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
|
||||
}
|
||||
|
||||
fn extract_x_gmail_labels(bytes: &[u8]) -> Option<String> {
|
||||
let mut values: Vec<String> = Vec::new();
|
||||
let mut current: Option<String> = None;
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let nl = bytes[i..].iter().position(|&b| b == b'\n');
|
||||
let (line, advance) = match nl {
|
||||
Some(p) => (&bytes[i..i + p], p + 1),
|
||||
None => (&bytes[i..], bytes.len() - i),
|
||||
};
|
||||
let line = line.strip_suffix(b"\r").unwrap_or(line);
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
let is_continuation = line
|
||||
.first()
|
||||
.map(|b| *b == b' ' || *b == b'\t')
|
||||
.unwrap_or(false);
|
||||
if is_continuation {
|
||||
if let Some(buf) = current.as_mut() {
|
||||
let extra = std::str::from_utf8(line).unwrap_or("").trim_start();
|
||||
if !extra.is_empty() {
|
||||
buf.push(' ');
|
||||
buf.push_str(extra);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Some(buf) = current.take() {
|
||||
values.push(buf);
|
||||
}
|
||||
if let Some(colon) = line.iter().position(|&b| b == b':') {
|
||||
let name = &line[..colon];
|
||||
if name.eq_ignore_ascii_case(b"X-Gmail-Labels") {
|
||||
let value = &line[colon + 1..];
|
||||
let value = std::str::from_utf8(value).unwrap_or("").trim();
|
||||
current = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
i += advance;
|
||||
}
|
||||
if let Some(buf) = current.take() {
|
||||
values.push(buf);
|
||||
}
|
||||
if values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(values.join(","))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_labels_from_simple_message() {
|
||||
let bytes = b"From: a@b\r\n\
|
||||
X-Gmail-Labels: Inbox,Opened\r\n\
|
||||
Subject: hi\r\n\
|
||||
\r\n\
|
||||
body\r\n";
|
||||
assert_eq!(
|
||||
extract_x_gmail_labels(bytes),
|
||||
Some("Inbox,Opened".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_labels_handles_lf_only_line_endings() {
|
||||
let bytes = b"From: a@b\nX-Gmail-Labels: Inbox\n\nbody\n";
|
||||
assert_eq!(extract_x_gmail_labels(bytes), Some("Inbox".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_labels_handles_folded_continuation() {
|
||||
let bytes = b"From: a@b\r\n\
|
||||
X-Gmail-Labels: Inbox,\r\n\
|
||||
\tImportant,Github\r\n\
|
||||
Subject: hi\r\n\
|
||||
\r\n\
|
||||
body\r\n";
|
||||
assert_eq!(
|
||||
extract_x_gmail_labels(bytes),
|
||||
Some("Inbox, Important,Github".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_labels_returns_none_when_header_absent() {
|
||||
let bytes = b"From: a@b\r\nSubject: hi\r\n\r\nbody\r\n";
|
||||
assert_eq!(extract_x_gmail_labels(bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_labels_case_insensitive_name_match() {
|
||||
let bytes = b"x-gmail-labels: Sent\r\n\r\n";
|
||||
assert_eq!(extract_x_gmail_labels(bytes), Some("Sent".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_labels_concatenates_multiple_header_occurrences() {
|
||||
let bytes = b"X-Gmail-Labels: Inbox\r\nX-Gmail-Labels: Github\r\n\r\nbody";
|
||||
assert_eq!(
|
||||
extract_x_gmail_labels(bytes),
|
||||
Some("Inbox,Github".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_labels_stops_at_blank_line() {
|
||||
let bytes = b"X-Gmail-Labels: Inbox\r\n\r\nX-Gmail-Labels: NOT-A-HEADER\r\n";
|
||||
assert_eq!(extract_x_gmail_labels(bytes), Some("Inbox".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_int_array_emits_compact_form() {
|
||||
assert_eq!(json_int_array(&[1, 2, 3]), "[1,2,3]");
|
||||
assert_eq!(json_int_array(&[]), "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_string_array_quotes_each_value() {
|
||||
assert_eq!(
|
||||
json_string_array(&["$seen".to_owned(), "$flagged".to_owned()]),
|
||||
"[\"$seen\",\"$flagged\"]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_received_at_prefers_envelope_internal_date() {
|
||||
let got = pick_received_at(1763065233, Some("2025-05-12T10:00:00+02:00"));
|
||||
assert_eq!(got, "2025-11-13T20:20:33Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_received_at_falls_back_to_date_header() {
|
||||
let got = pick_received_at(0, Some("2025-05-12T10:00:00+02:00"));
|
||||
assert_eq!(got, "2025-05-12T10:00:00+02:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_received_at_last_resort_is_now_in_rfc3339() {
|
||||
let got = pick_received_at(0, None);
|
||||
assert!(got.ends_with("Z") || got.contains("+") || got.contains("-"));
|
||||
assert!(got.contains("T"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::io::BufRead;
|
||||
|
||||
use time::{Date, Month, OffsetDateTime, Time, UtcOffset};
|
||||
|
||||
pub struct MessageIterator<T> {
|
||||
reader: T,
|
||||
message: Option<Message>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct Message {
|
||||
internal_date: u64,
|
||||
envelope_from: String,
|
||||
contents: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<T> MessageIterator<T>
|
||||
where
|
||||
T: BufRead,
|
||||
{
|
||||
pub fn new(reader: T) -> MessageIterator<T> {
|
||||
MessageIterator {
|
||||
reader,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Iterator for MessageIterator<T>
|
||||
where
|
||||
T: BufRead,
|
||||
{
|
||||
type Item = std::io::Result<Message>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let mut line = Vec::with_capacity(80);
|
||||
|
||||
loop {
|
||||
match self.reader.read_until(b'\n', &mut line) {
|
||||
Ok(0) => return self.message.take().map(Ok),
|
||||
Ok(_) => {}
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
|
||||
if line.starts_with(b"From ") {
|
||||
let finished = self.message.take().map(Ok);
|
||||
let header = std::str::from_utf8(&line).unwrap_or("");
|
||||
self.message = Some(Message::from_envelope(header));
|
||||
if finished.is_some() {
|
||||
return finished;
|
||||
}
|
||||
line.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(message) = &mut self.message {
|
||||
if line.first() != Some(&b'>') {
|
||||
message.contents.extend_from_slice(&line);
|
||||
line.clear();
|
||||
continue;
|
||||
}
|
||||
let non_gt = line.iter().position(|&c| c != b'>').unwrap_or(line.len());
|
||||
if line[non_gt..].starts_with(b"From ") {
|
||||
message.contents.extend_from_slice(&line[1..]);
|
||||
} else {
|
||||
message.contents.extend_from_slice(&line);
|
||||
}
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Message {
|
||||
fn from_envelope(header: &str) -> Self {
|
||||
let trimmed = header.strip_prefix("From ").unwrap_or(header);
|
||||
let (envelope_from, date_str) = match trimmed.split_once(' ') {
|
||||
Some((from, rest)) => (from.trim().to_owned(), rest),
|
||||
None => (String::new(), ""),
|
||||
};
|
||||
let internal_date = parse_envelope_date(date_str).unwrap_or(0);
|
||||
Self {
|
||||
internal_date,
|
||||
envelope_from,
|
||||
contents: Vec::with_capacity(1024),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal_date(&self) -> u64 {
|
||||
self.internal_date
|
||||
}
|
||||
|
||||
pub fn envelope_from(&self) -> &str {
|
||||
&self.envelope_from
|
||||
}
|
||||
|
||||
pub fn contents(&self) -> &[u8] {
|
||||
&self.contents
|
||||
}
|
||||
|
||||
pub fn unwrap_contents(self) -> Vec<u8> {
|
||||
self.contents
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_envelope_date(s: &str) -> Option<u64> {
|
||||
let parts: Vec<&str> = s.split_whitespace().collect();
|
||||
let (month_pos, day_pos, time_pos, offset_pos, year_pos) = match parts.len() {
|
||||
5 => (1, 2, 3, None, 4),
|
||||
6 => (1, 2, 3, Some(4), 5),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let month = parse_month(parts[month_pos])?;
|
||||
let day: u8 = parts[day_pos].parse().ok()?;
|
||||
let (hour, minute, second) = parse_hms(parts[time_pos])?;
|
||||
let year: i32 = parts[year_pos].parse().ok()?;
|
||||
let offset = match offset_pos {
|
||||
Some(p) => parse_offset(parts[p])?,
|
||||
None => UtcOffset::UTC,
|
||||
};
|
||||
|
||||
let date = Date::from_calendar_date(year, month, day).ok()?;
|
||||
let time = Time::from_hms(hour, minute, second).ok()?;
|
||||
let ts = OffsetDateTime::new_in_offset(date, time, offset).unix_timestamp();
|
||||
u64::try_from(ts).ok()
|
||||
}
|
||||
|
||||
fn parse_month(s: &str) -> Option<Month> {
|
||||
Some(match s {
|
||||
x if x.eq_ignore_ascii_case("jan") => Month::January,
|
||||
x if x.eq_ignore_ascii_case("feb") => Month::February,
|
||||
x if x.eq_ignore_ascii_case("mar") => Month::March,
|
||||
x if x.eq_ignore_ascii_case("apr") => Month::April,
|
||||
x if x.eq_ignore_ascii_case("may") => Month::May,
|
||||
x if x.eq_ignore_ascii_case("jun") => Month::June,
|
||||
x if x.eq_ignore_ascii_case("jul") => Month::July,
|
||||
x if x.eq_ignore_ascii_case("aug") => Month::August,
|
||||
x if x.eq_ignore_ascii_case("sep") => Month::September,
|
||||
x if x.eq_ignore_ascii_case("oct") => Month::October,
|
||||
x if x.eq_ignore_ascii_case("nov") => Month::November,
|
||||
x if x.eq_ignore_ascii_case("dec") => Month::December,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_hms(s: &str) -> Option<(u8, u8, u8)> {
|
||||
let mut it = s.split(':');
|
||||
let h: u8 = it.next()?.parse().ok()?;
|
||||
let m: u8 = it.next()?.parse().ok()?;
|
||||
let sec: u8 = it.next()?.parse().ok()?;
|
||||
if it.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some((h, m, sec))
|
||||
}
|
||||
|
||||
fn parse_offset(s: &str) -> Option<UtcOffset> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 5 {
|
||||
return None;
|
||||
}
|
||||
let sign: i8 = match bytes[0] {
|
||||
b'+' => 1,
|
||||
b'-' => -1,
|
||||
_ => return None,
|
||||
};
|
||||
let hh: i8 = std::str::from_utf8(&bytes[1..3]).ok()?.parse().ok()?;
|
||||
let mm: i8 = std::str::from_utf8(&bytes[3..5]).ok()?.parse().ok()?;
|
||||
UtcOffset::from_hms(sign * hh, sign * mm, 0).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classic_ctime_envelope_parses() {
|
||||
let raw = b"From [email protected] Sat Jan 3 01:05:34 1996\n\
|
||||
Message 1\n\
|
||||
\n\
|
||||
From [email protected] Tue Jul 23 19:39:23 2002\n\
|
||||
Message 2\n\
|
||||
\n";
|
||||
let mut it = MessageIterator::new(&raw[..]);
|
||||
let m1 = it.next().unwrap().unwrap();
|
||||
assert_eq!(m1.envelope_from(), "[email protected]");
|
||||
assert_eq!(m1.internal_date(), 820631134);
|
||||
assert_eq!(m1.contents(), b"Message 1\n\n");
|
||||
let m2 = it.next().unwrap().unwrap();
|
||||
assert_eq!(m2.envelope_from(), "[email protected]");
|
||||
assert_eq!(m2.internal_date(), 1027453163);
|
||||
assert!(it.next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mboxrd_unescape_recovers_lines() {
|
||||
let raw = b"From a@b Tue Aug 6 13:34:34 2002\n\
|
||||
Message 3\n\
|
||||
>From hello\n\
|
||||
>>From world\n\
|
||||
>>>From test\n\
|
||||
\n";
|
||||
let m = MessageIterator::new(&raw[..]).next().unwrap().unwrap();
|
||||
assert_eq!(
|
||||
m.contents(),
|
||||
b"Message 3\nFrom hello\n>From world\n>>From test\n\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mboxrd_leaves_non_from_lines_starting_with_gt_alone() {
|
||||
let raw = b"From a@b Mon Jan 15 15:30:00 2018\n\
|
||||
Message 4\n\
|
||||
> From\n\
|
||||
>F\n";
|
||||
let m = MessageIterator::new(&raw[..]).next().unwrap().unwrap();
|
||||
assert_eq!(m.contents(), b"Message 4\n> From\n>F\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takeout_envelope_with_utc_offset_parses() {
|
||||
let raw = b"From 1848707889910060830@xxx Thu Nov 13 20:20:33 +0000 2025\n\
|
||||
X-GM-THRID: 1848707889910060830\n\
|
||||
X-Gmail-Labels: Inbox,Opened\n\
|
||||
Subject: hi\n\
|
||||
\n\
|
||||
body\n";
|
||||
let m = MessageIterator::new(&raw[..]).next().unwrap().unwrap();
|
||||
assert_eq!(m.envelope_from(), "1848707889910060830@xxx");
|
||||
assert_eq!(m.internal_date(), 1763065233);
|
||||
assert!(m.contents().starts_with(b"X-GM-THRID:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takeout_envelope_with_negative_offset_applies_offset() {
|
||||
let raw = b"From x@y Thu Nov 13 12:20:33 -0800 2025\n\
|
||||
body\n";
|
||||
let m = MessageIterator::new(&raw[..]).next().unwrap().unwrap();
|
||||
assert_eq!(m.internal_date(), 1763065233);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_envelope_date_yields_zero_not_error() {
|
||||
let raw = b"From who knows what\nbody\n";
|
||||
let m = MessageIterator::new(&raw[..]).next().unwrap().unwrap();
|
||||
assert_eq!(m.internal_date(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_multiple_takeout_format_messages() {
|
||||
let raw = b"From 1@xxx Thu Nov 13 20:20:33 +0000 2025\n\
|
||||
X-GM-THRID: 1\n\
|
||||
X-Gmail-Labels: Inbox\n\
|
||||
Subject: A\n\
|
||||
\n\
|
||||
body a\n\
|
||||
From 2@xxx Thu Nov 13 21:00:00 +0000 2025\n\
|
||||
X-GM-THRID: 2\n\
|
||||
X-Gmail-Labels: Sent,Archived\n\
|
||||
Subject: B\n\
|
||||
\n\
|
||||
body b\n";
|
||||
let parsed: Vec<_> = MessageIterator::new(&raw[..])
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert_eq!(parsed[0].envelope_from(), "1@xxx");
|
||||
assert_eq!(parsed[1].envelope_from(), "2@xxx");
|
||||
assert!(parsed[0].contents().starts_with(b"X-GM-THRID: 1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PlannedMailbox {
|
||||
pub canonical_path: String,
|
||||
pub leaf: String,
|
||||
pub parent_path: Option<String>,
|
||||
pub role: Option<&'static str>,
|
||||
pub ephemeral: bool,
|
||||
}
|
||||
|
||||
pub fn assemble<F>(input: &[String], role_for: F) -> Vec<PlannedMailbox>
|
||||
where
|
||||
F: Fn(&str) -> Option<&'static str>,
|
||||
{
|
||||
let explicit: BTreeSet<String> = input.iter().cloned().collect();
|
||||
let mut all_paths: BTreeSet<String> = BTreeSet::new();
|
||||
for p in &explicit {
|
||||
for prefix in prefixes(p) {
|
||||
all_paths.insert(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
let mut planned: Vec<PlannedMailbox> = Vec::with_capacity(all_paths.len());
|
||||
for path in all_paths {
|
||||
let (parent_path, leaf) = split_path(&path);
|
||||
let ephemeral = !explicit.contains(&path);
|
||||
let role = role_for(&path);
|
||||
planned.push(PlannedMailbox {
|
||||
canonical_path: path,
|
||||
leaf,
|
||||
parent_path,
|
||||
role,
|
||||
ephemeral,
|
||||
});
|
||||
}
|
||||
planned
|
||||
}
|
||||
|
||||
pub fn vanished_depth_sort(paths: &mut [String]) {
|
||||
paths.sort_by(|a, b| depth_of(b).cmp(&depth_of(a)).then_with(|| a.cmp(b)));
|
||||
}
|
||||
|
||||
fn prefixes(path: &str) -> Vec<String> {
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let mut out = Vec::with_capacity(parts.len());
|
||||
for i in 1..=parts.len() {
|
||||
out.push(parts[..i].join("/"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn split_path(path: &str) -> (Option<String>, String) {
|
||||
match path.rsplit_once('/') {
|
||||
Some((parent, leaf)) => (Some(parent.to_owned()), leaf.to_owned()),
|
||||
None => (None, path.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn depth_of(path: &str) -> usize {
|
||||
path.bytes().filter(|b| *b == b'/').count() + 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn no_roles(_: &str) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn inbox_role(p: &str) -> Option<&'static str> {
|
||||
if p == "Inbox" { Some("inbox") } else { None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_paths_pass_through_unchanged() {
|
||||
let r = assemble(
|
||||
&["Inbox".to_owned(), "Sent".to_owned(), "Github".to_owned()],
|
||||
no_roles,
|
||||
);
|
||||
let names: Vec<&str> = r.iter().map(|m| m.canonical_path.as_str()).collect();
|
||||
assert_eq!(names, vec!["Github", "Inbox", "Sent"]);
|
||||
assert!(r.iter().all(|m| !m.ephemeral));
|
||||
assert!(r.iter().all(|m| m.parent_path.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_path_creates_ephemeral_parent() {
|
||||
let r = assemble(
|
||||
&["Label_001/Label_002_under_Label_001".to_owned()],
|
||||
no_roles,
|
||||
);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[0].canonical_path, "Label_001");
|
||||
assert!(r[0].ephemeral);
|
||||
assert_eq!(r[0].parent_path, None);
|
||||
assert_eq!(r[1].canonical_path, "Label_001/Label_002_under_Label_001");
|
||||
assert!(!r[1].ephemeral);
|
||||
assert_eq!(r[1].parent_path, Some("Label_001".to_owned()));
|
||||
assert_eq!(r[1].leaf, "Label_002_under_Label_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_parent_is_not_marked_ephemeral() {
|
||||
let r = assemble(
|
||||
&[
|
||||
"Label_001".to_owned(),
|
||||
"Label_001/Sub".to_owned(),
|
||||
"Label_001/Other".to_owned(),
|
||||
],
|
||||
no_roles,
|
||||
);
|
||||
let parent = r.iter().find(|m| m.canonical_path == "Label_001").unwrap();
|
||||
assert!(!parent.ephemeral);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_level_deep_path_creates_all_missing_ancestors() {
|
||||
let r = assemble(&["A/B/C".to_owned()], no_roles);
|
||||
let names: Vec<&str> = r.iter().map(|m| m.canonical_path.as_str()).collect();
|
||||
assert_eq!(names, vec!["A", "A/B", "A/B/C"]);
|
||||
assert!(r[0].ephemeral);
|
||||
assert!(r[1].ephemeral);
|
||||
assert!(!r[2].ephemeral);
|
||||
assert_eq!(r[2].parent_path, Some("A/B".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_closure_consulted_for_ephemeral_parents_too() {
|
||||
let r = assemble(&["Inbox/Sub".to_owned()], inbox_role);
|
||||
let inbox = r.iter().find(|m| m.canonical_path == "Inbox").unwrap();
|
||||
assert!(inbox.ephemeral, "auto-created from the nested label");
|
||||
assert_eq!(
|
||||
inbox.role,
|
||||
Some("inbox"),
|
||||
"an auto-created Inbox is still the inbox"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_closure_applies_to_explicit_top_level() {
|
||||
let r = assemble(&["Inbox".to_owned()], inbox_role);
|
||||
assert_eq!(r[0].role, Some("inbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_is_parents_before_children() {
|
||||
let r = assemble(
|
||||
&[
|
||||
"Zeta/Alpha".to_owned(),
|
||||
"Alpha".to_owned(),
|
||||
"Beta/Gamma/Delta".to_owned(),
|
||||
],
|
||||
no_roles,
|
||||
);
|
||||
let names: Vec<&str> = r.iter().map(|m| m.canonical_path.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"Alpha",
|
||||
"Beta",
|
||||
"Beta/Gamma",
|
||||
"Beta/Gamma/Delta",
|
||||
"Zeta",
|
||||
"Zeta/Alpha",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_inputs_dedup() {
|
||||
let r = assemble(
|
||||
&["Inbox".to_owned(), "Inbox".to_owned(), "Inbox".to_owned()],
|
||||
no_roles,
|
||||
);
|
||||
assert_eq!(r.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanished_depth_sort_orders_leaves_first() {
|
||||
let mut v = vec![
|
||||
"Foo".to_owned(),
|
||||
"Foo/Bar/Baz".to_owned(),
|
||||
"Other".to_owned(),
|
||||
"Foo/Bar".to_owned(),
|
||||
];
|
||||
vanished_depth_sort(&mut v);
|
||||
assert_eq!(v, vec!["Foo/Bar/Baz", "Foo/Bar", "Foo", "Other",]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanished_depth_sort_handles_ties_lexicographically() {
|
||||
let mut v = vec!["B".to_owned(), "A".to_owned(), "C".to_owned()];
|
||||
vanished_depth_sort(&mut v);
|
||||
assert_eq!(v, vec!["A", "B", "C"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_yields_empty_output() {
|
||||
let r = assemble(&[], no_roles);
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::logging::Logger;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum FileKind {
|
||||
Mbox,
|
||||
Ics,
|
||||
Vcf,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
fn from_extension(ext: &str) -> Option<FileKind> {
|
||||
let lower = ext.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"mbox" => Some(FileKind::Mbox),
|
||||
"ics" => Some(FileKind::Ics),
|
||||
"vcf" => Some(FileKind::Vcf),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredFile {
|
||||
pub path: PathBuf,
|
||||
pub kind: FileKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WalkResult {
|
||||
pub files: Vec<DiscoveredFile>,
|
||||
pub symlink_cycles: u64,
|
||||
pub io_failures: u64,
|
||||
}
|
||||
|
||||
impl WalkResult {
|
||||
pub fn by_kind(&self, kind: FileKind) -> impl Iterator<Item = &DiscoveredFile> {
|
||||
self.files.iter().filter(move |f| f.kind == kind)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk(root: &Path) -> io::Result<WalkResult> {
|
||||
walk_with_logger(root, Logger::from_flags(true, 0))
|
||||
}
|
||||
|
||||
pub fn walk_with_logger(root: &Path, logger: Logger) -> io::Result<WalkResult> {
|
||||
let mut result = WalkResult::default();
|
||||
let mut visited: HashSet<(u64, u64)> = HashSet::new();
|
||||
walk_dir(root, &mut result, &mut visited, logger)?;
|
||||
result.files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn walk_dir(
|
||||
dir: &Path,
|
||||
result: &mut WalkResult,
|
||||
visited: &mut HashSet<(u64, u64)>,
|
||||
logger: Logger,
|
||||
) -> io::Result<()> {
|
||||
let metadata = fs::metadata(dir)?;
|
||||
let key = (metadata.dev(), metadata.ino());
|
||||
if !visited.insert(key) {
|
||||
result.symlink_cycles += 1;
|
||||
logger.warn(&format!(
|
||||
"takeout walk: symlink cycle broken at {dir:?} (target already visited)"
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(it) => it,
|
||||
Err(e) => {
|
||||
result.io_failures += 1;
|
||||
logger.warn(&format!("takeout walk: read_dir {dir:?}: {e}"));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
result.io_failures += 1;
|
||||
logger.warn(&format!("takeout walk: entry in {dir:?}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
result.io_failures += 1;
|
||||
logger.warn(&format!("takeout walk: file_type {path:?}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if file_type.is_dir() {
|
||||
walk_dir(&path, result, visited, logger)?;
|
||||
continue;
|
||||
}
|
||||
if file_type.is_symlink() {
|
||||
let target_meta = match fs::metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
result.io_failures += 1;
|
||||
logger.warn(&format!("takeout walk: symlink target {path:?}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if target_meta.is_dir() {
|
||||
walk_dir(&path, result, visited, logger)?;
|
||||
continue;
|
||||
}
|
||||
if !target_meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
} else if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(kind) = classify(&path) {
|
||||
result.files.push(DiscoveredFile { path, kind });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn classify(path: &Path) -> Option<FileKind> {
|
||||
let ext = path.extension()?.to_str()?;
|
||||
FileKind::from_extension(ext)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::symlink;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn touch(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path, b"").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_only_matching_extensions() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let root = td.path();
|
||||
touch(&root.join("Mail/All.mbox"));
|
||||
touch(&root.join("Calendar/cal.ics"));
|
||||
touch(&root.join("Contacts/c.vcf"));
|
||||
touch(&root.join("Calendar/meet_settings.json"));
|
||||
touch(&root.join("archive_browser.html"));
|
||||
touch(&root.join("Contacts/photo.jpg"));
|
||||
let r = walk(root).unwrap();
|
||||
assert_eq!(r.files.len(), 3);
|
||||
let mut kinds: Vec<FileKind> = r.files.iter().map(|f| f.kind).collect();
|
||||
kinds.sort_by_key(|k| format!("{k:?}"));
|
||||
assert_eq!(kinds, vec![FileKind::Ics, FileKind::Mbox, FileKind::Vcf]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_match_is_case_insensitive() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let root = td.path();
|
||||
touch(&root.join("Inbox.MBOX"));
|
||||
touch(&root.join("Cal.Ics"));
|
||||
touch(&root.join("All.Vcf"));
|
||||
let r = walk(root).unwrap();
|
||||
assert_eq!(r.files.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurses_into_nested_directories() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let root = td.path();
|
||||
touch(&root.join("Takeout/Mail/a.mbox"));
|
||||
touch(&root.join("Takeout/Calendar/deep/b.ics"));
|
||||
touch(&root.join("Takeout/Contacts/All Contacts/c.vcf"));
|
||||
let r = walk(root).unwrap();
|
||||
assert_eq!(r.files.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_non_matching_tree_returns_empty() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let root = td.path();
|
||||
touch(&root.join("readme.txt"));
|
||||
touch(&root.join("nested/photo.jpg"));
|
||||
let r = walk(root).unwrap();
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_are_sorted_for_determinism() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let root = td.path();
|
||||
touch(&root.join("z.mbox"));
|
||||
touch(&root.join("a.mbox"));
|
||||
touch(&root.join("m.ics"));
|
||||
let r = walk(root).unwrap();
|
||||
let names: Vec<&str> = r
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.path.file_name().unwrap().to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["a.mbox", "m.ics", "z.mbox"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn by_kind_filters_returned_iterator() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let root = td.path();
|
||||
touch(&root.join("a.mbox"));
|
||||
touch(&root.join("b.mbox"));
|
||||
touch(&root.join("c.ics"));
|
||||
let r = walk(root).unwrap();
|
||||
assert_eq!(r.by_kind(FileKind::Mbox).count(), 2);
|
||||
assert_eq!(r.by_kind(FileKind::Ics).count(), 1);
|
||||
assert_eq!(r.by_kind(FileKind::Vcf).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symlink_to_directory_is_followed_once_not_infinitely() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let root = td.path();
|
||||
touch(&root.join("real/a.mbox"));
|
||||
symlink(root.join("real"), root.join("link-to-real")).unwrap();
|
||||
let r = walk(root).unwrap();
|
||||
assert_eq!(r.files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symlink_cycle_is_broken() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let root = td.path();
|
||||
fs::create_dir_all(root.join("a/b")).unwrap();
|
||||
symlink(root.join("a"), root.join("a/b/loop")).unwrap();
|
||||
let r = walk(root).unwrap();
|
||||
assert!(r.symlink_cycles >= 1, "cycle counter incremented");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_directory_basenames_traverse_fine() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let root = td.path();
|
||||
touch(&root.join(".hidden/m.mbox"));
|
||||
let r = walk(root).unwrap();
|
||||
assert_eq!(r.files.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn normalize_message_id(raw: &str) -> String {
|
||||
let t = raw.trim();
|
||||
let t = t.strip_prefix('<').unwrap_or(t);
|
||||
let t = t.strip_suffix('>').unwrap_or(t);
|
||||
t.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub fn fold_name(name: &str) -> String {
|
||||
name.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub fn blake3_fields(fields: &[&str]) -> [u8; 32] {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for (i, f) in fields.iter().enumerate() {
|
||||
if i > 0 {
|
||||
hasher.update(b"\x1f");
|
||||
}
|
||||
hasher.update(f.as_bytes());
|
||||
}
|
||||
*hasher.finalize().as_bytes()
|
||||
}
|
||||
|
||||
pub fn blake3_bytes(bytes: &[u8]) -> [u8; 32] {
|
||||
*blake3::hash(bytes).as_bytes()
|
||||
}
|
||||
|
||||
pub fn identity_key(name: &str, email: &str) -> [u8; 32] {
|
||||
blake3_fields(&[name, &email.to_lowercase()])
|
||||
}
|
||||
|
||||
pub fn participant_identity_key(calendar_address: &str, name: &str) -> [u8; 32] {
|
||||
blake3_fields(&[&calendar_address.to_lowercase(), name])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum EmailKey {
|
||||
MessageId(String),
|
||||
Fallback([u8; 32]),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EmailIndex {
|
||||
|
||||
pub mids: Vec<String>,
|
||||
|
||||
pub fb: [u8; 32],
|
||||
}
|
||||
|
||||
fn join_addrs(addrs: &[String]) -> String {
|
||||
addrs
|
||||
.iter()
|
||||
.map(|a| a.to_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
pub fn email_index(
|
||||
message_ids: &[String],
|
||||
from: &[String],
|
||||
subject: &str,
|
||||
sent_at: &str,
|
||||
to: &[String],
|
||||
) -> EmailIndex {
|
||||
let mut mids: Vec<String> = message_ids
|
||||
.iter()
|
||||
.map(|m| normalize_message_id(m))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
mids.sort();
|
||||
mids.dedup();
|
||||
let fb = blake3_fields(&[&join_addrs(from), subject, sent_at, &join_addrs(to)]);
|
||||
EmailIndex { mids, fb }
|
||||
}
|
||||
|
||||
pub fn index_to_json(idx: &EmailIndex) -> String {
|
||||
json!({ "m": idx.mids, "f": hex(&idx.fb) }).to_string()
|
||||
}
|
||||
|
||||
pub fn index_from_json(text: &str) -> EmailIndex {
|
||||
let v: Value = serde_json::from_str(text).unwrap_or(Value::Null);
|
||||
let mids = v
|
||||
.get("m")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let fb = v
|
||||
.get("f")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(unhex)
|
||||
.unwrap_or([0u8; 32]);
|
||||
EmailIndex { mids, fb }
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8; 32]) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in bytes {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(s, "{b:02x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Option<[u8; 32]> {
|
||||
if s.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
for (i, slot) in out.iter_mut().enumerate() {
|
||||
*slot = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn email_key(idx: &EmailIndex) -> EmailKey {
|
||||
if idx.mids.is_empty() {
|
||||
EmailKey::Fallback(idx.fb)
|
||||
} else {
|
||||
EmailKey::MessageId(idx.mids.join("\x1f"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn email_keys(indices: &[EmailIndex]) -> Vec<EmailKey> {
|
||||
indices.iter().map(email_key).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn message_id_normalization_strips_brackets_and_lowercases() {
|
||||
assert_eq!(normalize_message_id(" <[email protected]> "), "[email protected]");
|
||||
assert_eq!(normalize_message_id("plain@x"), "plain@x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_fold_is_case_insensitive() {
|
||||
assert_eq!(fold_name(" Sales "), fold_name("sales"));
|
||||
assert_ne!(fold_name("Sales"), fold_name("sale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_collapse_ignores_signature() {
|
||||
assert_eq!(
|
||||
identity_key("Alice", "[email protected]"),
|
||||
identity_key("Alice", "[email protected]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participant_identity_key_lowercases_address_only() {
|
||||
let a = participant_identity_key("MailTo:User@Host", "Bob");
|
||||
let b = participant_identity_key("mailto:user@host", "Bob");
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, participant_identity_key("mailto:user@host", "bob"));
|
||||
}
|
||||
|
||||
fn idx(mids: &[&str], from: &[&str], subj: &str, sent: &str, to: &[&str]) -> EmailIndex {
|
||||
let m: Vec<String> = mids.iter().map(|s| (*s).to_owned()).collect();
|
||||
let f: Vec<String> = from.iter().map(|s| (*s).to_owned()).collect();
|
||||
let t: Vec<String> = to.iter().map(|s| (*s).to_owned()).collect();
|
||||
email_index(&m, &f, subj, sent, &t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_id_array_is_normalized_sorted_and_order_independent() {
|
||||
let a = idx(&["<B@h>", "<a@H>"], &[], "", "", &[]);
|
||||
let b = idx(&["<a@h>", "<b@h>"], &[], "", "", &[]);
|
||||
assert_eq!(a.mids, vec!["a@h".to_owned(), "b@h".to_owned()]);
|
||||
assert_eq!(a.mids, b.mids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_single_message_id_uses_message_id_key() {
|
||||
let keys = email_keys(&[
|
||||
idx(
|
||||
&["<m1@h>"],
|
||||
&["a@x"],
|
||||
"Hi",
|
||||
"2020-01-01T00:00:00Z",
|
||||
&["b@y"],
|
||||
),
|
||||
idx(
|
||||
&["<m2@h>"],
|
||||
&["a@x"],
|
||||
"Yo",
|
||||
"2020-01-02T00:00:00Z",
|
||||
&["b@y"],
|
||||
),
|
||||
]);
|
||||
assert_eq!(keys[0], EmailKey::MessageId("m1@h".to_owned()));
|
||||
assert_eq!(keys[1], EmailKey::MessageId("m2@h".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_message_id_matches_regardless_of_other_fields() {
|
||||
let keys = email_keys(&[
|
||||
idx(
|
||||
&["<dup@h>"],
|
||||
&["a@x"],
|
||||
"S",
|
||||
"2020-01-01T00:00:00Z",
|
||||
&["b@y"],
|
||||
),
|
||||
idx(
|
||||
&["<dup@h>"],
|
||||
&["c@x"],
|
||||
"T",
|
||||
"2020-01-02T00:00:00Z",
|
||||
&["d@y"],
|
||||
),
|
||||
]);
|
||||
assert_eq!(keys[0], EmailKey::MessageId("dup@h".to_owned()));
|
||||
assert_eq!(keys[0], keys[1], "same Message-ID is the same message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_absent_message_id_falls_back_multi_is_still_message_id() {
|
||||
let keys = email_keys(&[
|
||||
idx(&[], &["a@x"], "S", "2020-01-01T00:00:00Z", &["b@y"]),
|
||||
idx(
|
||||
&["<a@h>", "<b@h>"],
|
||||
&["a@x"],
|
||||
"S",
|
||||
"2020-01-01T00:00:00Z",
|
||||
&["b@y"],
|
||||
),
|
||||
]);
|
||||
assert!(matches!(keys[0], EmailKey::Fallback(_)));
|
||||
assert_eq!(keys[1], EmailKey::MessageId("a@h\u{1f}b@h".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_is_case_insensitive_on_addresses_and_offset_preserving_on_sent_at() {
|
||||
let a = idx(&[], &["A@X"], "Subj", "2020-01-01T10:00:00+02:00", &["B@Y"]);
|
||||
let b = idx(&[], &["a@x"], "Subj", "2020-01-01T10:00:00+02:00", &["b@y"]);
|
||||
let c = idx(&[], &["a@x"], "Subj", "2020-01-01T08:00:00Z", &["b@y"]);
|
||||
assert_eq!(a.fb, b.fb);
|
||||
assert_ne!(a.fb, c.fb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_json_roundtrips() {
|
||||
let i = idx(&["<m@h>"], &["a@x"], "S", "2020-01-01T00:00:00Z", &["b@y"]);
|
||||
let j = index_to_json(&i);
|
||||
let back = index_from_json(&j);
|
||||
assert_eq!(i, back);
|
||||
assert_eq!(email_keys(&[i]), email_keys(&[back]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_stored_json_degrades_to_empty_fallback() {
|
||||
let back = index_from_json("{}");
|
||||
assert!(back.mids.is_empty());
|
||||
assert_eq!(back.fb, [0u8; 32]);
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod emailmeta;
|
||||
pub mod export;
|
||||
pub mod import_dav;
|
||||
pub mod import_exchange_ews;
|
||||
pub mod import_exchange_graph;
|
||||
pub mod import_imap;
|
||||
pub mod import_jmap;
|
||||
pub mod import_maildir;
|
||||
pub mod import_managesieve;
|
||||
pub mod import_takeout;
|
||||
pub mod keys;
|
||||
pub mod prune;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db;
|
||||
use crate::error::Error;
|
||||
|
||||
pub(crate) fn table_name(ty: ObjectType) -> &'static str {
|
||||
match ty {
|
||||
ObjectType::Mailbox => "mailboxes",
|
||||
ObjectType::Email => "emails",
|
||||
ObjectType::Identity => "identities",
|
||||
ObjectType::SieveScript => "sieve_scripts",
|
||||
ObjectType::AddressBook => "address_books",
|
||||
ObjectType::ContactCard => "contact_cards",
|
||||
ObjectType::Calendar => "calendars",
|
||||
ObjectType::CalendarEvent => "calendar_events",
|
||||
ObjectType::ParticipantIdentity => "participant_identities",
|
||||
ObjectType::FileNode => "file_nodes",
|
||||
}
|
||||
}
|
||||
use crate::jmap::account::AccountSelector;
|
||||
use crate::jmap::http::{Auth, HttpClient, RetryPolicy};
|
||||
use crate::logging::Logger;
|
||||
use crate::types::ObjectType;
|
||||
|
||||
pub struct CommonConfig {
|
||||
pub archive: PathBuf,
|
||||
pub threads: usize,
|
||||
pub dry_run: bool,
|
||||
pub max_retries: u32,
|
||||
pub allow_invalid_certs: bool,
|
||||
pub logger: Logger,
|
||||
}
|
||||
|
||||
pub struct ConnectConfig {
|
||||
pub url: String,
|
||||
pub auth: Auth,
|
||||
pub account: AccountSelector,
|
||||
}
|
||||
|
||||
pub struct ImportConfig {
|
||||
pub connect: ConnectConfig,
|
||||
pub objects: Option<Vec<ObjectType>>,
|
||||
pub allow_source_change: bool,
|
||||
}
|
||||
|
||||
pub struct ExportConfig {
|
||||
pub connect: ConnectConfig,
|
||||
pub objects: Option<Vec<ObjectType>>,
|
||||
pub prune: bool,
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct TypeCounts {
|
||||
pub created: u64,
|
||||
pub fetched: u64,
|
||||
pub deleted: u64,
|
||||
pub skipped: u64,
|
||||
pub failed: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Summary {
|
||||
pub per_type: Vec<(&'static str, TypeCounts)>,
|
||||
pub retries_observed: u64,
|
||||
pub retry_after_sleeps: u64,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
pub fn any_failed(&self) -> bool {
|
||||
self.per_type.iter().any(|(_, c)| c.failed > 0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Context {
|
||||
pub conn: Connection,
|
||||
pub client: HttpClient,
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn open(common: CommonConfig, connect: &ConnectConfig) -> Result<Context, Error> {
|
||||
let conn = db::init::open(&common.archive)?;
|
||||
let client = HttpClient::new(
|
||||
connect.auth.clone(),
|
||||
RetryPolicy::new(common.max_retries),
|
||||
common.allow_invalid_certs,
|
||||
);
|
||||
Ok(Context {
|
||||
conn,
|
||||
client,
|
||||
common,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dry_run(&self) -> bool {
|
||||
self.common.dry_run
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TargetObj {
|
||||
pub id: String,
|
||||
pub matched: bool,
|
||||
pub protected: bool,
|
||||
pub may_delete: bool,
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
fn protected_with_ancestors(objs: &[TargetObj]) -> HashSet<String> {
|
||||
let by_id: HashMap<&str, &TargetObj> = objs.iter().map(|o| (o.id.as_str(), o)).collect();
|
||||
let mut protected: HashSet<String> = HashSet::new();
|
||||
for o in objs {
|
||||
if o.protected {
|
||||
let mut cur = Some(o.id.clone());
|
||||
while let Some(id) = cur {
|
||||
if !protected.insert(id.clone()) {
|
||||
break;
|
||||
}
|
||||
cur = by_id
|
||||
.get(id.as_str())
|
||||
.and_then(|x| x.parent.clone())
|
||||
.filter(|p| by_id.contains_key(p.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
protected
|
||||
}
|
||||
|
||||
fn depth(id: &str, by_id: &HashMap<&str, &TargetObj>) -> usize {
|
||||
let mut d = 0;
|
||||
let mut cur = by_id.get(id).and_then(|o| o.parent.clone());
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
while let Some(p) = cur {
|
||||
if !seen.insert(p.clone()) || !by_id.contains_key(p.as_str()) {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = by_id.get(p.as_str()).and_then(|o| o.parent.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
pub fn candidates(objs: &[TargetObj], tree: bool) -> Vec<String> {
|
||||
let protected = protected_with_ancestors(objs);
|
||||
let by_id: HashMap<&str, &TargetObj> = objs.iter().map(|o| (o.id.as_str(), o)).collect();
|
||||
let mut out: Vec<&TargetObj> = objs
|
||||
.iter()
|
||||
.filter(|o| !o.matched && o.may_delete && !protected.contains(&o.id))
|
||||
.collect();
|
||||
if tree {
|
||||
out.sort_by(|a, b| {
|
||||
depth(&b.id, &by_id)
|
||||
.cmp(&depth(&a.id, &by_id))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
} else {
|
||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
}
|
||||
out.into_iter().map(|o| o.id.clone()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn o(id: &str, matched: bool, protected: bool, parent: Option<&str>) -> TargetObj {
|
||||
TargetObj {
|
||||
id: id.to_owned(),
|
||||
matched,
|
||||
protected,
|
||||
may_delete: true,
|
||||
parent: parent.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_unprotected_are_candidates() {
|
||||
let objs = vec![
|
||||
o("a", true, false, None),
|
||||
o("b", false, false, None),
|
||||
o("c", false, true, None),
|
||||
];
|
||||
assert_eq!(candidates(&objs, false), vec!["b".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_excludes_ancestors() {
|
||||
let objs = vec![
|
||||
o("root", false, false, None),
|
||||
o("mid", false, false, Some("root")),
|
||||
o("leaf", false, true, Some("mid")),
|
||||
];
|
||||
assert!(candidates(&objs, true).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn may_delete_false_is_skipped() {
|
||||
let mut x = o("x", false, false, None);
|
||||
x.may_delete = false;
|
||||
assert!(candidates(&[x], false).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_destroy_is_leaf_first() {
|
||||
let objs = vec![
|
||||
o("root", false, false, None),
|
||||
o("child", false, false, Some("root")),
|
||||
o("grand", false, false, Some("child")),
|
||||
];
|
||||
assert_eq!(
|
||||
candidates(&objs, true),
|
||||
vec!["grand".to_owned(), "child".to_owned(), "root".to_owned()]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user