mod attach;
pub mod gate;
pub mod manifest;
pub mod pace;
mod push;
pub mod wizard;
mod worker;
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use async_imap::types::NameAttribute;
use futures::TryStreamExt;
use indicatif::MultiProgress;
use crate::commands::job::email_sync::sink;
use crate::commands::keyring::email::identity::Identity;
use crate::core::job::Job;
use crate::core::rclone::config::RemoteTarget;
use manifest::Batch;
use worker::JobSummary;
pub(crate) const DEFAULT_MAX_CONNECTIONS: usize = 2;
pub(crate) struct ImportContext {
pub identity: Identity,
pub secret: String,
pub staging_dir: PathBuf,
pub scratch_dir: PathBuf,
}
#[derive(Debug, Default)]
pub(crate) struct IdentitySummary {
pub alias: String,
pub mailboxes: usize,
pub pending_messages: usize,
}
pub(crate) struct PendingMailbox {
pub mailbox: String,
pub mailbox_relpath: PathBuf,
pub uids: Vec<u32>,
}
#[tracing::instrument(skip_all, fields(identity = %ctx.identity.alias, email = %ctx.identity.email))]
pub(crate) async fn gather_pending(
ctx: &ImportContext,
multi_progress: &MultiProgress,
) -> Result<(Vec<PendingMailbox>, IdentitySummary), String> {
let _ = multi_progress.println(format!("Connecting to {}...", ctx.identity.alias));
let mut session = worker::connect_with_retry(ctx).await?;
let names: Vec<_> = session
.list(None, Some("*"))
.await
.map_err(|err| format!("failed to list mailboxes: {err}"))?
.try_collect()
.await
.map_err(|err| format!("failed to list mailboxes: {err}"))?;
let mailboxes: Vec<(String, Option<String>)> = names
.iter()
.filter(|name| !name.attributes().contains(&NameAttribute::NoSelect))
.map(|name| {
(
name.name().to_string(),
name.delimiter().map(str::to_string),
)
})
.collect();
let done = manifest::done_uids(&manifest::load_checkpoint(&ctx.staging_dir)?);
let mut pending_mailboxes = Vec::new();
let mut summary = IdentitySummary {
alias: ctx.identity.alias.clone(),
..Default::default()
};
let bar = sink::new_progress_bar(
format!("{} scan", ctx.identity.alias),
mailboxes.len() as u64,
multi_progress,
);
for (mailbox_name, delimiter) in &mailboxes {
let mailbox_relpath = sink::sanitize_mailbox_path(mailbox_name, delimiter.as_deref());
let marker_dir = ctx.staging_dir.join(&mailbox_relpath);
fs::create_dir_all(&marker_dir)
.map_err(|err| format!("failed to create {}: {err}", marker_dir.display()))?;
let mailbox_response = session
.examine(mailbox_name)
.await
.map_err(|err| format!("failed to open '{mailbox_name}' read-only: {err}"))?;
let current_validity = mailbox_response.uid_validity.unwrap_or(0);
if sink::is_stale(sink::read_uidvalidity(&marker_dir), current_validity) {
manifest::clear_checkpoint_for_mailbox(&ctx.staging_dir, mailbox_name)?;
}
sink::write_uidvalidity(&marker_dir, current_validity)?;
let server_uids: HashSet<u32> = session
.uid_search("ALL")
.await
.map_err(|err| format!("failed to search '{mailbox_name}': {err}"))?;
let done_here: HashSet<u32> = done
.iter()
.filter(|(mailbox, _)| mailbox == mailbox_name)
.map(|(_, uid)| *uid)
.collect();
let pending_uids = sink::missing_uids(&server_uids, &done_here);
if pending_uids.is_empty() {
bar.inc(1);
continue;
}
summary.mailboxes += 1;
summary.pending_messages += pending_uids.len();
pending_mailboxes.push(PendingMailbox {
mailbox: mailbox_name.clone(),
mailbox_relpath,
uids: pending_uids,
});
bar.inc(1);
}
bar.finish();
let _ = session.logout().await;
Ok((pending_mailboxes, summary))
}
pub(crate) fn batches_from_pending(pending: &[PendingMailbox], batch_size: usize) -> Vec<Batch> {
pending
.iter()
.flat_map(|mailbox| {
manifest::split_into_batches(&mailbox.uids, batch_size)
.into_iter()
.map(|uids| Batch {
mailbox: mailbox.mailbox.clone(),
mailbox_relpath: mailbox.mailbox_relpath.clone(),
uids,
})
})
.collect()
}
pub(crate) struct ImportJob {
pub contexts: Vec<ImportContext>,
pub destination: RemoteTarget,
pub messages_per_minute: usize,
pub batch_size: usize,
pub max_connections: usize,
pub log_dir: PathBuf,
}
pub(crate) struct ImportPlan {
pub pending_by_identity: Vec<Vec<PendingMailbox>>,
pub summaries: Vec<IdentitySummary>,
}
impl Job for ImportJob {
type Plan = ImportPlan;
type Summary = JobSummary;
async fn gather(&self) -> Result<ImportPlan, String> {
let multi_progress = MultiProgress::new();
let mut pending_by_identity = Vec::with_capacity(self.contexts.len());
let mut summaries = Vec::with_capacity(self.contexts.len());
for ctx in &self.contexts {
let (pending, summary) = gather_pending(ctx, &multi_progress).await?;
pending_by_identity.push(pending);
summaries.push(summary);
}
Ok(ImportPlan {
pending_by_identity,
summaries,
})
}
async fn run(
self,
plan: ImportPlan,
_concurrency: usize,
push_concurrency: usize,
) -> Result<JobSummary, String> {
worker::run_email_import_job(self, plan.pending_by_identity, push_concurrency).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pending(mailbox: &str, uids: Vec<u32>) -> PendingMailbox {
PendingMailbox {
mailbox: mailbox.to_string(),
mailbox_relpath: PathBuf::from(mailbox.to_ascii_lowercase()),
uids,
}
}
#[test]
fn batches_from_pending_chunks_each_mailbox_at_the_batch_size() {
let pending = vec![
pending("INBOX", (1..=25).collect()),
pending("Archive", vec![1, 2]),
];
let batches = batches_from_pending(&pending, 10);
assert_eq!(batches.len(), 4);
assert_eq!(batches[0].uids.len(), 10);
assert_eq!(batches[2].uids.len(), 5);
assert_eq!(batches[3].mailbox, "Archive");
assert_eq!(batches[3].uids, vec![1, 2]);
}
#[test]
fn batches_from_pending_carries_each_mailboxs_relpath_through() {
let batches = batches_from_pending(&[pending("INBOX", vec![1])], 10);
assert_eq!(batches[0].mailbox_relpath, PathBuf::from("inbox"));
}
#[test]
fn batches_from_pending_is_empty_for_no_pending_work() {
assert!(batches_from_pending(&[], 10).is_empty());
assert!(batches_from_pending(&[pending("INBOX", Vec::new())], 10).is_empty());
}
#[test]
fn default_max_connections_is_conservative() {
const {
assert!(DEFAULT_MAX_CONNECTIONS >= 1);
assert!(
DEFAULT_MAX_CONNECTIONS
< crate::commands::job::email_sync::DEFAULT_MAX_CONNECTIONS_PER_IDENTITY
);
}
}
}