Files
inbuxa-migrate/src/cli.rs
T
2026-05-29 18:02:15 +02:00

1365 lines
39 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::path::PathBuf;
use clap::{ArgAction, Args, Parser, Subcommand};
use regex::Regex;
use crate::error::Error;
use crate::exchange_ews::oauth::OAuthFlow;
use crate::exchange_ews::types::MailboxKind;
use crate::exchange_graph::types::{EventBodyFormat, MailboxKind as GraphMailboxKind};
use crate::inspect::InspectConfig;
use crate::jmap::account::AccountSelector;
use crate::jmap::http::Auth;
use crate::logging::Logger;
use crate::secret;
use crate::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
use crate::sync::import_exchange_ews::{EwsAuth, EwsImportConfig};
use crate::sync::import_exchange_graph::{GraphAuth, GraphImportConfig};
use crate::sync::import_imap::{ImapAuth, ImapImportConfig};
use crate::sync::import_maildir::MaildirImportConfig;
use crate::sync::import_managesieve::{ManageSieveAuth, ManageSieveImportConfig};
use crate::sync::import_takeout::TakeoutImportConfig;
use crate::sync::{CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
use crate::types::{ObjectType, parse_object_list};
#[derive(Parser)]
#[command(
name = "vandelay",
version,
about = "Vandelay: the JMAP importer-exporter"
)]
pub struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
#[command(about = "Read a source account into a local archive")]
Import {
#[command(subcommand)]
source: Source,
},
#[command(about = "Push a local archive into a target JMAP server")]
Export(Box<ExportArgs>),
#[command(about = "Show the contents of a local archive (read-only)")]
Inspect(Box<InspectArgs>),
}
#[derive(Subcommand)]
enum Source {
#[command(about = "JMAP server")]
Jmap(Box<JmapImportArgs>),
#[command(about = "IMAP server")]
Imap(Box<ImapImportArgs>),
#[command(about = "CalDAV server")]
Caldav(Box<DavImportArgs>),
#[command(about = "CardDAV server")]
Carddav(Box<DavImportArgs>),
#[command(about = "WebDAV server (plain files)")]
Webdav(Box<DavImportArgs>),
#[command(about = "ManageSieve server (Sieve scripts only)")]
Managesieve(Box<ManageSieveImportArgs>),
#[command(about = "Local Maildir++ tree")]
Maildir(Box<MaildirImportArgs>),
#[command(about = "Google Takeout (or any mbox/ics/vcf directory tree)")]
Takeout(Box<TakeoutImportArgs>),
#[command(about = "MS Exchange via EWS (SOAP/XML)", name = "exchange-ews")]
ExchangeEws(Box<ExchangeEwsImportArgs>),
#[command(
about = "MS Exchange Online via Microsoft Graph",
name = "exchange-graph"
)]
ExchangeGraph(Box<ExchangeGraphImportArgs>),
}
#[derive(Args)]
struct GlobalArgs {
#[arg(
short = 'j',
long,
value_name = "N",
help = "Worker pool size (default: logical CPUs)"
)]
threads: Option<usize>,
#[arg(long, help = "Compute and report the full plan; perform no writes")]
dry_run: bool,
#[arg(
short = 'v',
long,
action = ArgAction::Count,
conflicts_with = "quiet",
help = "Increase log verbosity (repeatable, max -vvv)",
long_help = "Increase log verbosity (repeatable, max -vvv).\n \
(default) per-type start/finish lines and a final summary\n \
-v per-chunk progress, account resolution, retry notices\n \
-vv every JMAP call (method, accountId, args, status, timing)\n \
-vvv full request/response bodies and backoff delays (stderr)"
)]
verbose: u8,
#[arg(
short = 'q',
long,
help = "Quiet: warnings and errors only (verbosity level 0)"
)]
quiet: bool,
#[arg(
long,
value_name = "N",
default_value_t = 5,
help = "Max retry attempts per request on transient failures"
)]
max_retries: u32,
#[arg(long, help = "Accept self-signed / invalid TLS certificates")]
allow_invalid_certs: bool,
}
#[derive(Args)]
#[command(group(clap::ArgGroup::new("auth").required(true).args(["auth_basic", "auth_bearer"])))]
#[command(group(clap::ArgGroup::new("account").required(true).args(["account_id", "account_name"])))]
struct JmapImportArgs {
#[arg(long, value_name = "URL", help = "Base URL or full JMAP session URL")]
url: String,
#[arg(long, value_name = "USER", help = "HTTP Basic user")]
auth_basic: Option<String>,
#[arg(
long,
value_name = "PASS",
help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
)]
auth_password: Option<String>,
#[arg(
long,
value_name = "TOKEN",
num_args = 0..=1,
help = "Bearer token (value optional: falls back to $VANDELAY_TOKEN or prompt)"
)]
auth_bearer: Option<Option<String>>,
#[arg(long, value_name = "ID", help = "Source account id")]
account_id: Option<String>,
#[arg(long, value_name = "NAME", help = "Source account name")]
account_name: Option<String>,
#[arg(
long,
value_name = "LIST",
help = "Comma-separated type list (default: all supported)"
)]
objects: Option<String>,
#[arg(
long,
help = "Permit importing when the archive records a different JMAP source"
)]
allow_source_change: bool,
#[command(flatten)]
global: GlobalArgs,
#[arg(
value_name = "ARCHIVE",
help = "Local SQLite archive (created if absent)"
)]
archive: PathBuf,
}
#[derive(Args)]
#[command(group(clap::ArgGroup::new("auth").required(true).args(["auth_basic", "auth_bearer"])))]
struct ImapImportArgs {
#[arg(
long,
value_name = "URL",
help = "imap://host[:port] or imaps://host[:port]"
)]
url: String,
#[arg(
long,
value_name = "USER",
help = "PLAIN (preferred) with LOGIN fallback"
)]
auth_basic: Option<String>,
#[arg(
long,
value_name = "PASS",
help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
)]
auth_password: Option<String>,
#[arg(
long,
value_name = "TOKEN",
num_args = 0..=1,
help = "Bearer token for XOAUTH2 (value optional: falls back to $VANDELAY_TOKEN or prompt)"
)]
auth_bearer: Option<Option<String>>,
#[arg(
long,
value_name = "USER",
help = "XOAUTH2 identity (required with --auth-bearer)"
)]
auth_user: Option<String>,
#[arg(long, help = "Permit credentials on imap:// without STARTTLS")]
allow_cleartext: bool,
#[arg(long, help = "Enable COMPRESS=DEFLATE post-auth (off by default)")]
compress: bool,
#[arg(
long,
value_name = "REGEX",
action = ArgAction::Append,
help = "Folder include filter (repeatable)"
)]
include: Vec<String>,
#[arg(
long,
value_name = "REGEX",
action = ArgAction::Append,
help = "Folder exclude filter (repeatable)"
)]
exclude: Vec<String>,
#[arg(
long,
value_name = "ROLE",
action = ArgAction::Append,
help = "Drop folders matching a role (repeatable)"
)]
exclude_special: Vec<String>,
#[arg(
long,
value_name = "NAME",
action = ArgAction::Append,
help = "Exact include; mutually exclusive with --include/--exclude"
)]
folder: Vec<String>,
#[arg(long, help = "Keep only folders returned by LSUB")]
subscribed_only: bool,
#[arg(long, help = "Disable SPECIAL-USE name-heuristic fallback")]
noautomap: bool,
#[arg(long, help = "Import \\Deleted messages with the $deleted keyword")]
include_deleted: bool,
#[arg(
long,
value_name = "N",
default_value_t = 256,
help = "UIDs per metadata FETCH chunk"
)]
fetch_batch: usize,
#[arg(
long,
value_name = "N",
help = "Parallel IMAP connections for body fetch (1..=8). Default: min(--threads, 8)"
)]
imap_connections: Option<usize>,
#[arg(
long,
help = "Permit importing when the archive records a different IMAP source"
)]
allow_source_change: bool,
#[command(flatten)]
global: GlobalArgs,
#[arg(
value_name = "ARCHIVE",
help = "Local SQLite archive (created if absent)"
)]
archive: PathBuf,
}
#[derive(Args)]
#[command(group(clap::ArgGroup::new("auth").required(true).args(["auth_basic", "auth_bearer"])))]
#[command(group(clap::ArgGroup::new("account").required(true).args(["account_id", "account_name"])))]
struct ExportArgs {
#[arg(long, value_name = "URL", help = "Base URL or full JMAP session URL")]
url: String,
#[arg(long, value_name = "USER", help = "HTTP Basic user")]
auth_basic: Option<String>,
#[arg(
long,
value_name = "PASS",
help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
)]
auth_password: Option<String>,
#[arg(
long,
value_name = "TOKEN",
num_args = 0..=1,
help = "Bearer token (value optional: falls back to $VANDELAY_TOKEN or prompt)"
)]
auth_bearer: Option<Option<String>>,
#[arg(long, value_name = "ID", help = "Target account id")]
account_id: Option<String>,
#[arg(long, value_name = "NAME", help = "Target account name")]
account_name: Option<String>,
#[arg(
long,
value_name = "LIST",
help = "Types to export (default: all present in archive)"
)]
objects: Option<String>,
#[arg(long, help = "Delete target objects that match nothing in the archive")]
prune: bool,
#[arg(long, help = "Skip the interactive --prune confirmation")]
yes: bool,
#[command(flatten)]
global: GlobalArgs,
#[arg(value_name = "ARCHIVE", help = "Local SQLite archive")]
archive: PathBuf,
}
#[derive(Args)]
struct InspectArgs {
#[arg(value_name = "ARCHIVE", help = "Local SQLite archive")]
archive: PathBuf,
#[arg(
value_name = "TYPE",
help = "Object type to dump (omit for a summary of all types)"
)]
target: Option<String>,
#[arg(
long,
value_name = "N",
help = "Maximum rows to show (omit to dump every row)"
)]
limit: Option<usize>,
#[arg(
long,
value_name = "N",
default_value_t = 0,
help = "Skip the first N rows"
)]
offset: usize,
}
pub enum Action {
Import(CommonConfig, ImportConfig),
ImportImap(CommonConfig, ImapImportConfig),
ImportDav(CommonConfig, DavImportConfig),
ImportManageSieve(CommonConfig, ManageSieveImportConfig),
ImportMaildir(CommonConfig, MaildirImportConfig),
ImportTakeout(CommonConfig, TakeoutImportConfig),
ImportExchangeEws(CommonConfig, EwsImportConfig),
ImportExchangeGraph(CommonConfig, GraphImportConfig),
Export(CommonConfig, ExportConfig),
Inspect(InspectConfig),
}
#[derive(Args)]
#[command(group(clap::ArgGroup::new("auth").required(true).args(["auth_basic", "auth_bearer"])))]
pub struct ManageSieveImportArgs {
#[arg(
long,
value_name = "URL",
help = "sieve://host[:port] or sieves://host[:port]"
)]
url: String,
#[arg(
long,
value_name = "USER",
help = "SASL PLAIN (preferred) with LOGIN fallback"
)]
auth_basic: Option<String>,
#[arg(
long,
value_name = "PASS",
help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
)]
auth_password: Option<String>,
#[arg(
long,
value_name = "TOKEN",
num_args = 0..=1,
help = "Bearer token for OAUTHBEARER (value optional: $VANDELAY_TOKEN or prompt)"
)]
auth_bearer: Option<Option<String>>,
#[arg(
long,
value_name = "USER",
help = "OAUTHBEARER identity (required with --auth-bearer)"
)]
auth_user: Option<String>,
#[arg(long, help = "Permit credentials on sieve:// without STARTTLS")]
allow_cleartext: bool,
#[arg(
long,
help = "Permit importing when the archive records a different ManageSieve source"
)]
allow_source_change: bool,
#[command(flatten)]
global: GlobalArgs,
#[arg(
value_name = "ARCHIVE",
help = "Local SQLite archive (created if absent)"
)]
archive: PathBuf,
}
#[derive(Args)]
#[command(group(clap::ArgGroup::new("auth").required(true).args(["auth_basic", "auth_bearer"])))]
pub struct DavImportArgs {
#[arg(long, value_name = "URL", help = "http(s)://host[:port][/path]")]
url: String,
#[arg(long, value_name = "USER", help = "HTTP Basic user")]
auth_basic: Option<String>,
#[arg(
long,
value_name = "PASS",
help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
)]
auth_password: Option<String>,
#[arg(
long,
value_name = "TOKEN",
num_args = 0..=1,
help = "Bearer token (value optional: $VANDELAY_TOKEN or prompt)"
)]
auth_bearer: Option<Option<String>>,
#[arg(long, help = "Permit credentials on http:// (no TLS)")]
allow_cleartext: bool,
#[arg(
long,
value_name = "N",
default_value_t = 4,
help = "Parallel DAV worker connections (hard cap 8)"
)]
dav_connections: usize,
#[arg(
long,
value_name = "N",
default_value_t = 50,
help = "Hrefs per calendar-/addressbook-multiget REPORT batch"
)]
multiget_batch: usize,
#[arg(
long,
help = "Permit importing when the archive records a different DAV source"
)]
allow_source_change: bool,
#[command(flatten)]
global: GlobalArgs,
#[arg(
value_name = "ARCHIVE",
help = "Local SQLite archive (created if absent)"
)]
archive: PathBuf,
}
impl Cli {
pub fn resolve(self) -> Result<Action, Error> {
match self.command {
Command::Import { source } => resolve_import(source),
Command::Export(args) => resolve_export(*args),
Command::Inspect(args) => resolve_inspect(*args),
}
}
}
fn resolve_import(source: Source) -> Result<Action, Error> {
match source {
Source::Jmap(args) => resolve_jmap_import(*args),
Source::Imap(args) => resolve_imap_import(*args),
Source::Caldav(args) => resolve_dav_import(*args, DavKindArg::Caldav),
Source::Carddav(args) => resolve_dav_import(*args, DavKindArg::Carddav),
Source::Webdav(args) => resolve_dav_import(*args, DavKindArg::Webdav),
Source::Managesieve(args) => resolve_managesieve_import(*args),
Source::Maildir(args) => resolve_maildir_import(*args),
Source::Takeout(args) => resolve_takeout_import(*args),
Source::ExchangeEws(args) => resolve_exchange_ews_import(*args),
Source::ExchangeGraph(args) => resolve_exchange_graph_import(*args),
}
}
fn resolve_managesieve_import(args: ManageSieveImportArgs) -> Result<Action, Error> {
let common = common_config(&args.global, args.archive);
let auth = resolve_managesieve_auth(
args.auth_basic.as_deref(),
args.auth_password.as_deref(),
args.auth_bearer.as_ref(),
args.auth_user.as_deref(),
)?;
Ok(Action::ImportManageSieve(
common,
ManageSieveImportConfig {
url: args.url,
auth,
allow_cleartext: args.allow_cleartext,
allow_source_change: args.allow_source_change,
},
))
}
fn resolve_managesieve_auth(
auth_basic: Option<&str>,
auth_password: Option<&str>,
auth_bearer: Option<&Option<String>>,
auth_user: Option<&str>,
) -> Result<ManageSieveAuth, Error> {
if let Some(user) = auth_basic {
if auth_user.is_some() {
return Err(Error::Usage(
"--auth-user is only valid with --auth-bearer".to_owned(),
));
}
let password = secret::resolve(auth_password, "VANDELAY_PASSWORD", "password")?;
return Ok(ManageSieveAuth::Basic {
user: user.to_owned(),
password,
});
}
if auth_password.is_some() {
return Err(Error::Usage(
"--auth-password is only valid together with --auth-basic".to_owned(),
));
}
let bearer = auth_bearer.ok_or_else(|| {
Error::Usage("exactly one of --auth-basic / --auth-bearer is required".to_owned())
})?;
let user = auth_user
.ok_or_else(|| Error::Usage("--auth-user is required with --auth-bearer".to_owned()))?
.to_owned();
let token = secret::resolve(bearer.as_deref(), "VANDELAY_TOKEN", "bearer token")?;
Ok(ManageSieveAuth::Bearer { user, token })
}
#[derive(Args)]
pub struct MaildirImportArgs {
#[arg(
value_name = "MAILDIR",
help = "Path to the Maildir++ root (a directory containing cur/ new/ tmp/)"
)]
maildir: PathBuf,
#[arg(
value_name = "ARCHIVE",
help = "Local SQLite archive (created if absent)"
)]
archive: PathBuf,
#[arg(
long,
value_name = "REGEX",
action = ArgAction::Append,
help = "Folder include filter against canonical name (repeatable)"
)]
include: Vec<String>,
#[arg(
long,
value_name = "REGEX",
action = ArgAction::Append,
help = "Folder exclude filter against canonical name (repeatable)"
)]
exclude: Vec<String>,
#[arg(
long,
value_name = "NAME",
action = ArgAction::Append,
help = "Exact include; mutually exclusive with --include/--exclude"
)]
folder: Vec<String>,
#[arg(long, help = "Disable the name-heuristic role assignment")]
noautomap: bool,
#[arg(long, help = "Import T-flagged messages with the $deleted keyword")]
include_deleted: bool,
#[arg(
long,
help = "Permit importing when the archive records a different maildir path"
)]
allow_source_change: bool,
#[command(flatten)]
global: GlobalArgs,
}
fn resolve_maildir_import(args: MaildirImportArgs) -> Result<Action, Error> {
let common = common_config(&args.global, args.archive);
if !args.folder.is_empty() && (!args.include.is_empty() || !args.exclude.is_empty()) {
return Err(Error::Usage(
"--folder is mutually exclusive with --include/--exclude".to_owned(),
));
}
let include = compile_regexes(&args.include, "--include")?;
let exclude = compile_regexes(&args.exclude, "--exclude")?;
Ok(Action::ImportMaildir(
common,
MaildirImportConfig {
maildir: args.maildir,
include,
exclude,
folder: args.folder,
automap: !args.noautomap,
include_deleted: args.include_deleted,
allow_source_change: args.allow_source_change,
},
))
}
#[derive(Args)]
pub struct TakeoutImportArgs {
#[arg(
value_name = "PATH",
help = "Directory tree to scan recursively for .mbox / .ics / .vcf files"
)]
path: PathBuf,
#[arg(
value_name = "ARCHIVE",
help = "Local SQLite archive (created if absent)"
)]
archive: PathBuf,
#[arg(long, help = "Disable the system-label role assignment")]
noautomap: bool,
#[arg(
long,
help = "Permit importing when the archive records a takeout source at a different path"
)]
allow_source_change: bool,
#[command(flatten)]
global: GlobalArgs,
}
fn resolve_takeout_import(args: TakeoutImportArgs) -> Result<Action, Error> {
let common = common_config(&args.global, args.archive);
Ok(Action::ImportTakeout(
common,
TakeoutImportConfig {
takeout_root: args.path,
allow_source_change: args.allow_source_change,
automap: !args.noautomap,
},
))
}
fn resolve_dav_import(args: DavImportArgs, kind: DavKindArg) -> Result<Action, Error> {
let common = common_config(&args.global, args.archive);
let auth = resolve_dav_auth(
args.auth_basic.as_deref(),
args.auth_password.as_deref(),
args.auth_bearer.as_ref(),
)?;
let dav_connections = args.dav_connections.clamp(1, 8).min(common.threads.max(1));
let multiget_batch = args.multiget_batch.max(1);
Ok(Action::ImportDav(
common,
DavImportConfig {
kind,
url: args.url,
auth,
allow_cleartext: args.allow_cleartext,
dav_connections,
multiget_batch,
allow_source_change: args.allow_source_change,
},
))
}
fn resolve_dav_auth(
auth_basic: Option<&str>,
auth_password: Option<&str>,
auth_bearer: Option<&Option<String>>,
) -> Result<DavAuth, Error> {
if let Some(user) = auth_basic {
let password = secret::resolve(auth_password, "VANDELAY_PASSWORD", "password")?;
return Ok(DavAuth::Basic {
user: user.to_owned(),
password,
});
}
if auth_password.is_some() {
return Err(Error::Usage(
"--auth-password is only valid together with --auth-basic".to_owned(),
));
}
let bearer = auth_bearer.ok_or_else(|| {
Error::Usage("exactly one of --auth-basic / --auth-bearer is required".to_owned())
})?;
let token = secret::resolve(bearer.as_deref(), "VANDELAY_TOKEN", "bearer token")?;
Ok(DavAuth::Bearer { token })
}
fn resolve_jmap_import(args: JmapImportArgs) -> Result<Action, Error> {
let common = common_config(&args.global, args.archive);
let auth = resolve_auth(
args.auth_basic.as_deref(),
args.auth_password.as_deref(),
args.auth_bearer.as_ref(),
)?;
let account = resolve_account(args.account_id, args.account_name)?;
let objects = args.objects.as_deref().map(parse_object_list).transpose()?;
Ok(Action::Import(
common,
ImportConfig {
connect: ConnectConfig {
url: args.url,
auth,
account,
},
objects,
allow_source_change: args.allow_source_change,
},
))
}
fn resolve_imap_import(args: ImapImportArgs) -> Result<Action, Error> {
let common = common_config(&args.global, args.archive);
let auth = resolve_imap_auth(
args.auth_basic.as_deref(),
args.auth_password.as_deref(),
args.auth_bearer.as_ref(),
args.auth_user.as_deref(),
)?;
if !args.folder.is_empty() && (!args.include.is_empty() || !args.exclude.is_empty()) {
return Err(Error::Usage(
"--folder is mutually exclusive with --include/--exclude".to_owned(),
));
}
let include = compile_regexes(&args.include, "--include")?;
let exclude = compile_regexes(&args.exclude, "--exclude")?;
let imap_connections = args.imap_connections.unwrap_or(common.threads).clamp(1, 8);
Ok(Action::ImportImap(
common,
ImapImportConfig {
url: args.url,
auth,
allow_cleartext: args.allow_cleartext,
compress: args.compress,
include,
exclude,
exclude_special: args.exclude_special,
folder: args.folder,
subscribed_only: args.subscribed_only,
automap: !args.noautomap,
include_deleted: args.include_deleted,
fetch_batch: args.fetch_batch,
imap_connections,
allow_source_change: args.allow_source_change,
},
))
}
fn compile_regexes(raw: &[String], flag: &str) -> Result<Vec<Regex>, Error> {
let mut out = Vec::with_capacity(raw.len());
for r in raw {
let re =
Regex::new(r).map_err(|e| Error::Usage(format!("{flag} {r:?}: invalid regex: {e}")))?;
out.push(re);
}
Ok(out)
}
fn resolve_imap_auth(
auth_basic: Option<&str>,
auth_password: Option<&str>,
auth_bearer: Option<&Option<String>>,
auth_user: Option<&str>,
) -> Result<ImapAuth, Error> {
if let Some(user) = auth_basic {
if auth_user.is_some() {
return Err(Error::Usage(
"--auth-user is only valid with --auth-bearer".to_owned(),
));
}
let password = secret::resolve(auth_password, "VANDELAY_PASSWORD", "password")?;
return Ok(ImapAuth::Basic {
user: user.to_owned(),
password,
});
}
if auth_password.is_some() {
return Err(Error::Usage(
"--auth-password is only valid together with --auth-basic".to_owned(),
));
}
let bearer = auth_bearer.ok_or_else(|| {
Error::Usage("exactly one of --auth-basic / --auth-bearer is required".to_owned())
})?;
let user = auth_user
.ok_or_else(|| Error::Usage("--auth-user is required with --auth-bearer".to_owned()))?
.to_owned();
let token = secret::resolve(bearer.as_deref(), "VANDELAY_TOKEN", "bearer token")?;
Ok(ImapAuth::Bearer { user, token })
}
fn resolve_export(args: ExportArgs) -> Result<Action, Error> {
let common = common_config(&args.global, args.archive);
let auth = resolve_auth(
args.auth_basic.as_deref(),
args.auth_password.as_deref(),
args.auth_bearer.as_ref(),
)?;
let account = resolve_account(args.account_id, args.account_name)?;
let objects = args.objects.as_deref().map(parse_object_list).transpose()?;
Ok(Action::Export(
common,
ExportConfig {
connect: ConnectConfig {
url: args.url,
auth,
account,
},
objects,
prune: args.prune,
yes: args.yes,
},
))
}
fn resolve_inspect(args: InspectArgs) -> Result<Action, Error> {
let target = match args.target.as_deref() {
Some(token) => Some(ObjectType::parse(token)?),
None => None,
};
if let Some(limit) = args.limit
&& limit == 0
{
return Err(Error::Usage("--limit must be greater than zero".to_owned()));
}
Ok(Action::Inspect(InspectConfig {
archive: args.archive,
target,
limit: args.limit,
offset: args.offset,
}))
}
#[derive(Args)]
#[command(group(clap::ArgGroup::new("ews_auth").required(true).args([
"auth_basic", "auth_bearer",
])))]
pub struct ExchangeEwsImportArgs {
#[arg(long, value_name = "URL", help = "Fully-qualified EWS endpoint URL")]
url: Option<String>,
#[arg(
long,
value_name = "EMAIL",
help = "Target mailbox SMTP address (required for autodiscover and app-only OAuth)"
)]
mailbox: Option<String>,
#[arg(
long,
value_name = "KIND",
default_value = "primary",
help = "Mailbox surface: primary | archive | public-folders"
)]
mailbox_kind: String,
#[arg(long, value_name = "USER", help = "Basic auth user (on-prem only)")]
auth_basic: Option<String>,
#[arg(
long,
value_name = "PASS",
help = "Basic password (prefer $VANDELAY_PASSWORD or prompt)"
)]
auth_password: Option<String>,
#[arg(
long,
value_name = "TOKEN",
num_args = 0..=1,
help = "Bearer token (value optional: $VANDELAY_TOKEN or device-code flow)"
)]
auth_bearer: Option<Option<String>>,
#[arg(
long,
value_name = "TENANT",
help = "Entra tenant id or .onmicrosoft.com short name"
)]
ews_tenant: Option<String>,
#[arg(long, value_name = "ID", help = "Entra app (client) id")]
ews_client_id: Option<String>,
#[arg(
long,
value_name = "SECRET",
help = "OAuth client secret (app-only flow; prefer $VANDELAY_EWS_CLIENT_SECRET)"
)]
ews_client_secret: Option<String>,
#[arg(long, help = "Use the OAuth device-code interactive flow")]
ews_device_code: bool,
#[arg(
long,
value_name = "N",
default_value_t = 4,
help = "Parallel EWS worker connections (hard cap 8)"
)]
ews_connections: usize,
#[arg(
long,
value_name = "N",
default_value_t = 20,
help = "ItemIds per GetItem batch"
)]
ews_getitem_batch: usize,
#[arg(
long,
value_name = "N",
default_value_t = 5,
help = "AttachmentIds per GetAttachment batch"
)]
ews_attachment_batch: usize,
#[arg(long, help = "Force FindItem even when SyncFolderItems would work")]
ews_no_syncfolderitems: bool,
#[arg(
long,
help = "Permit importing when the archive records a different EWS source"
)]
allow_source_change: bool,
#[command(flatten)]
global: GlobalArgs,
#[arg(
value_name = "ARCHIVE",
help = "Local SQLite archive (created if absent)"
)]
archive: PathBuf,
}
fn resolve_exchange_ews_import(args: ExchangeEwsImportArgs) -> Result<Action, Error> {
let archive = args.archive.clone();
let common = common_config(&args.global, archive);
let mailbox_kind = match args.mailbox_kind.as_str() {
"primary" => MailboxKind::Primary,
"archive" => MailboxKind::Archive,
"public-folders" => MailboxKind::PublicFolders,
other => {
return Err(Error::Usage(format!(
"--mailbox-kind must be primary | archive | public-folders, got {other:?}"
)));
}
};
let auth = resolve_exchange_ews_auth(&args)?;
let ews_connections = args.ews_connections.clamp(1, 8).min(common.threads.max(1));
let config = EwsImportConfig {
url: args.url,
mailbox: args.mailbox,
mailbox_kind,
auth,
ews_connections,
getitem_batch: args.ews_getitem_batch.max(1),
attachment_batch: args.ews_attachment_batch.max(1),
use_syncfolderitems: !args.ews_no_syncfolderitems,
allow_source_change: args.allow_source_change,
};
Ok(Action::ImportExchangeEws(common, config))
}
fn resolve_exchange_ews_auth(args: &ExchangeEwsImportArgs) -> Result<EwsAuth, Error> {
if let Some(user) = args.auth_basic.as_deref() {
let password = secret::resolve(
args.auth_password.as_deref(),
"VANDELAY_PASSWORD",
"password",
)?;
return Ok(EwsAuth::Basic {
user: user.to_owned(),
password,
});
}
if args.auth_password.is_some() {
return Err(Error::Usage(
"--auth-password is only valid with --auth-basic".to_owned(),
));
}
let Some(bearer) = args.auth_bearer.as_ref() else {
return Err(Error::Usage(
"exactly one of --auth-basic / --auth-bearer is required".to_owned(),
));
};
if let Some(token) = bearer.clone() {
return Ok(EwsAuth::Bearer { token });
}
let tenant = args
.ews_tenant
.clone()
.unwrap_or_else(|| "common".to_owned());
let client_id = args.ews_client_id.clone().ok_or_else(|| {
Error::Usage(
"--ews-client-id is required when --auth-bearer is used without an inline token"
.to_owned(),
)
})?;
if args.ews_device_code {
return Ok(EwsAuth::OAuth(OAuthFlow::DeviceCode { tenant, client_id }));
}
if let Some(secret) = args.ews_client_secret.clone() {
let resolved = secret::resolve(
Some(secret.as_str()),
"VANDELAY_EWS_CLIENT_SECRET",
"client secret",
)?;
return Ok(EwsAuth::OAuth(OAuthFlow::ClientCredentials {
tenant,
client_id,
client_secret: resolved,
}));
}
if let Ok(env_secret) = std::env::var("VANDELAY_EWS_CLIENT_SECRET")
&& !env_secret.is_empty()
{
return Ok(EwsAuth::OAuth(OAuthFlow::ClientCredentials {
tenant,
client_id,
client_secret: env_secret,
}));
}
let token = secret::resolve(None, "VANDELAY_TOKEN", "bearer token")?;
Ok(EwsAuth::Bearer { token })
}
#[derive(Args)]
pub struct ExchangeGraphImportArgs {
#[arg(
long,
value_name = "UUID",
help = "Entra app (client) id (required unless --access-token is given)"
)]
client_id: Option<String>,
#[arg(
long,
value_name = "ID",
default_value = "common",
help = "Entra authority tenant"
)]
tenant: String,
#[arg(
long,
value_name = "UPN|UUID",
help = "Target a different mailbox the signed-in account can read"
)]
user: Option<String>,
#[arg(
long,
value_name = "KIND",
default_value = "primary",
value_parser = parse_graph_mailbox_kind,
help = "Mailbox surface: primary | archive (public-folders is rejected: use exchange-ews)"
)]
mailbox_kind: GraphMailboxKind,
#[arg(
long,
value_name = "TOKEN",
num_args = 0..=1,
help = "Pre-acquired bearer token (skip device-code flow). Resolves: flag value -> $VANDELAY_GRAPH_TOKEN -> prompt."
)]
access_token: Option<Option<String>>,
#[arg(
long,
value_name = "LIST",
help = "Comma-separated surface list (mail,calendar,contacts; default all)"
)]
objects: Option<String>,
#[arg(
long,
value_name = "FMT",
default_value = "text",
value_parser = parse_event_body_format,
help = "outlook.body-content-type for event GETs: text | html"
)]
event_body_format: EventBodyFormat,
#[arg(
long,
value_name = "N",
default_value_t = 4,
help = "Parallel Graph worker connections (hard cap 16)"
)]
graph_connections: usize,
#[arg(
long,
value_name = "N",
default_value_t = 100,
help = "Per-page size ($top query parameter, range [1, 1000])"
)]
top: usize,
#[arg(
long,
value_name = "URL",
default_value = "https://graph.microsoft.com/v1.0",
help = "Microsoft Graph API base URL (override for national clouds or tests)",
hide = true
)]
api_base: String,
#[arg(
long,
help = "Permit importing when the archive records a different exchange_graph source"
)]
allow_source_change: bool,
#[command(flatten)]
global: GlobalArgs,
#[arg(
value_name = "ARCHIVE",
help = "Local SQLite archive (created if absent)"
)]
archive: PathBuf,
}
fn parse_graph_mailbox_kind(s: &str) -> Result<GraphMailboxKind, String> {
GraphMailboxKind::parse(s).map_err(|e| e.to_string())
}
fn parse_event_body_format(s: &str) -> Result<EventBodyFormat, String> {
EventBodyFormat::parse(s).map_err(|e| e.to_string())
}
fn resolve_exchange_graph_import(args: ExchangeGraphImportArgs) -> Result<Action, Error> {
let common = common_config(&args.global, args.archive.clone());
let mailbox_kind = args.mailbox_kind;
let event_body_format = args.event_body_format;
let auth = resolve_graph_auth(
args.access_token.as_ref(),
args.client_id.as_deref(),
&args.tenant,
)?;
let objects = args
.objects
.as_deref()
.map(crate::types::parse_object_list)
.transpose()?;
let top = args.top.clamp(1, 1000);
let graph_connections = args
.graph_connections
.clamp(1, 16)
.min(common.threads.max(1));
Ok(Action::ImportExchangeGraph(
common,
GraphImportConfig {
auth,
api_base: args.api_base,
user_target: args.user,
mailbox_kind,
objects,
event_body_format,
graph_connections,
top,
allow_source_change: args.allow_source_change,
},
))
}
fn resolve_graph_auth(
access_token: Option<&Option<String>>,
client_id: Option<&str>,
tenant: &str,
) -> Result<GraphAuth, Error> {
if let Some(slot) = access_token {
let token = secret::resolve(slot.as_deref(), "VANDELAY_GRAPH_TOKEN", "bearer token")?;
return Ok(GraphAuth::PreAcquired { token });
}
let client_id = client_id
.ok_or_else(|| {
Error::Usage(
"--client-id is required for exchange-graph (unless --access-token is given)"
.to_owned(),
)
})?
.to_owned();
let authority = crate::exchange_graph::oauth::default_authority(tenant);
Ok(GraphAuth::DeviceCode {
authority,
client_id,
})
}
fn common_config(global: &GlobalArgs, archive: PathBuf) -> CommonConfig {
let threads = global
.threads
.filter(|n| *n > 0)
.unwrap_or_else(num_cpus::get)
.max(1);
CommonConfig {
archive,
threads,
dry_run: global.dry_run,
max_retries: global.max_retries,
allow_invalid_certs: global.allow_invalid_certs,
logger: Logger::from_flags(global.quiet, global.verbose),
}
}
fn resolve_auth(
auth_basic: Option<&str>,
auth_password: Option<&str>,
auth_bearer: Option<&Option<String>>,
) -> Result<Auth, Error> {
if let Some(user) = auth_basic {
let password = secret::resolve(auth_password, "VANDELAY_PASSWORD", "password")?;
return Ok(Auth::Basic {
user: user.to_owned(),
password,
});
}
if auth_password.is_some() {
return Err(Error::Usage(
"--auth-password is only valid together with --auth-basic".to_owned(),
));
}
let bearer = auth_bearer.ok_or_else(|| {
Error::Usage("exactly one of --auth-basic / --auth-bearer is required".to_owned())
})?;
let token = secret::resolve(bearer.as_deref(), "VANDELAY_TOKEN", "bearer token")?;
Ok(Auth::Bearer { token })
}
fn resolve_account(
account_id: Option<String>,
account_name: Option<String>,
) -> Result<AccountSelector, Error> {
match (account_id, account_name) {
(Some(id), None) => Ok(AccountSelector::Id(id)),
(None, Some(name)) => Ok(AccountSelector::Name(name)),
_ => Err(Error::Usage(
"exactly one of --account-id / --account-name is required".to_owned(),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
fn lock() -> std::sync::MutexGuard<'static, ()> {
static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
M.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn auth_password_without_basic_is_usage_error() {
let _g = lock();
unsafe {
env::remove_var("VANDELAY_TOKEN");
env::remove_var("VANDELAY_PASSWORD");
}
let bearer: Option<String> = Some("t".to_owned());
let r = resolve_auth(None, Some("hunter2"), Some(&bearer));
match r {
Err(Error::Usage(m)) => {
assert!(m.contains("auth-password"), "msg was: {m}");
}
other => panic!("expected Usage error, got {other:?}"),
}
}
#[test]
fn auth_basic_with_password_resolves() {
let _g = lock();
unsafe {
env::remove_var("VANDELAY_TOKEN");
env::remove_var("VANDELAY_PASSWORD");
}
let auth = resolve_auth(Some("alice"), Some("pw"), None).unwrap();
match auth {
Auth::Basic { user, password } => {
assert_eq!(user, "alice");
assert_eq!(password, "pw");
}
_ => panic!("expected Basic"),
}
}
#[test]
fn auth_bearer_inline_token_resolves() {
let _g = lock();
unsafe {
env::remove_var("VANDELAY_TOKEN");
env::remove_var("VANDELAY_PASSWORD");
}
let bearer: Option<String> = Some("t".to_owned());
let auth = resolve_auth(None, None, Some(&bearer)).unwrap();
assert!(matches!(auth, Auth::Bearer { token } if token == "t"));
}
}