mailmig: Stalwart -> SNM IMAP migration tool (initial)
This commit is contained in:
commit
be16176b6e
24 files changed
+7804
No files matched your search
+12
@@ -0,0 +1,12 @@
|
||||
/target
|
||||
/result*
|
||||
|
||||
# Working files of a real migration: contain account data and password hashes.
|
||||
/mailmig.toml
|
||||
/plan.toml
|
||||
/secrets.json
|
||||
/raw*.json
|
||||
/out/
|
||||
/reports/
|
||||
/dns-journal/
|
||||
/dkim/
|
||||
Generated
+2253
File diff suppressed because it is too large.
Load diff
+29
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "mailmig"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.104"
|
||||
base64 = "0.23.1"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] }
|
||||
clap = { version = "4.6.7", features = ["derive", "env"] }
|
||||
hex = "0.4.3"
|
||||
rand = "0.10.3"
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["json", "query", "rustls-no-provider", "http2"] }
|
||||
rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-pki-types = "1.15.1"
|
||||
rustls-platform-verifier = "0.7.1"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
sha-crypt = { version = "0.6.0", features = ["getrandom"] }
|
||||
sha2 = "0.11.1"
|
||||
tokio = { version = "1.54.1", features = ["full"] }
|
||||
tokio-rustls = { version = "0.26.6", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||
toml = "1.1.8"
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
wiremock = "0.6.5"
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
description = "mailmig: migrate mail from Stalwart to Simple NixOS Mailserver over IMAP";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||
# Same branch the cnx-network-clan mx1 runs.
|
||||
nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-26.05";
|
||||
nixos-mailserver.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
nixos-mailserver,
|
||||
}:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
inherit (pkgs) lib;
|
||||
mailmig = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "mailmig";
|
||||
version = (lib.importTOML ./Cargo.toml).package.version;
|
||||
src = lib.fileset.toSource {
|
||||
root = ./.;
|
||||
fileset = lib.fileset.unions [
|
||||
./Cargo.toml
|
||||
./Cargo.lock
|
||||
./src
|
||||
./mailmig.example.toml
|
||||
];
|
||||
};
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
meta.mainProgram = "mailmig";
|
||||
};
|
||||
in
|
||||
{
|
||||
packages.${system}.default = mailmig;
|
||||
|
||||
checks.${system} = {
|
||||
inherit mailmig;
|
||||
e2e = pkgs.testers.runNixOSTest (import ./tests/e2e.nix { inherit mailmig nixos-mailserver; });
|
||||
};
|
||||
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
inputsFrom = [ mailmig ];
|
||||
packages = [
|
||||
pkgs.rustfmt
|
||||
pkgs.clippy
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# mailmig configuration: Stalwart (old) -> Simple NixOS Mailserver (new).
|
||||
# Secrets are read from environment variables or files, never stored here.
|
||||
|
||||
# ---- old server: Stalwart -------------------------------------------------
|
||||
[source]
|
||||
host = "mail.b4l.co.th"
|
||||
tls = "implicit" # implicit (993) | starttls (143) | none
|
||||
# Stalwart impersonation: the master account needs the `impersonate`
|
||||
# permission; mailmig logs in as "<account>%<master>" with the master's
|
||||
# *main* password (app passwords are refused for impersonation).
|
||||
[source.auth]
|
||||
mode = "master"
|
||||
user = "mailmig@b4l.co.th"
|
||||
password_env = "MAILMIG_SRC_MASTER_PASSWORD"
|
||||
login_format = "{user}%{master}"
|
||||
|
||||
# Management API, used by `mailmig inventory`.
|
||||
[stalwart]
|
||||
url = "https://mail.b4l.co.th"
|
||||
kind = "auto" # auto | rest (<= 0.15) | jmap (>= 0.16)
|
||||
user = "admin"
|
||||
password_env = "MAILMIG_STALWART_ADMIN_PASSWORD"
|
||||
# token_env = "MAILMIG_STALWART_TOKEN" # API key instead of user/password
|
||||
|
||||
# ---- new server: Simple NixOS Mailserver ----------------------------------
|
||||
[destination]
|
||||
host = "mx1.cnx.email"
|
||||
tls = "implicit"
|
||||
# Temporary Dovecot master user from out/dovecot-master-user.nix.
|
||||
[destination.auth]
|
||||
mode = "master"
|
||||
user = "mailmig"
|
||||
password_env = "MAILMIG_DST_MASTER_PASSWORD"
|
||||
login_format = "{user}*{master}"
|
||||
|
||||
# ---- folders ----------------------------------------------------------------
|
||||
[folders]
|
||||
# Special-use folders (\Sent, \Trash, ...) are matched by attribute; Stalwart's
|
||||
# "Sent Items"/"Deleted Items"/"Junk Mail" land in SNM's Sent/Trash/Junk.
|
||||
exclude = ["Shared Folders/*"]
|
||||
separator_replacement = "_"
|
||||
# [folders.map]
|
||||
# "Old Name" = "New Name"
|
||||
|
||||
[sync]
|
||||
jobs = 4
|
||||
skip_deleted = true
|
||||
retries = 2
|
||||
|
||||
# ---- users (SNM / clan) ---------------------------------------------------------
|
||||
[snm]
|
||||
clan_machine = "mx1"
|
||||
clan_generator_prefix = "mail-passwd-"
|
||||
master_user = "mailmig"
|
||||
|
||||
# ---- DNS ------------------------------------------------------------------
|
||||
[dns]
|
||||
provider = "cloudflare" # cloudflare | zonefile
|
||||
api_token_env = "CLOUDFLARE_API_TOKEN" # Zone:DNS:Edit on the mail domains
|
||||
mx = ["10 mx1.cnx.email"]
|
||||
spf = "v=spf1 mx -all"
|
||||
dmarc = "v=DMARC1; p=quarantine; rua=mailto:dmarc@cnx.email"
|
||||
dkim_selector = "mail"
|
||||
dkim_dir = "dkim" # scp 'mx1:/var/dkim/*.txt' dkim/
|
||||
old_hosts = ["mail.b4l.co.th"]
|
||||
client_host = "mail.cnx.email"
|
||||
srv = true
|
||||
ttl = 300
|
||||
|
||||
# [dns.overrides."poyrecords.com"]
|
||||
# spf = "v=spf1 mx include:_spf.google.com -all"
|
||||
@@ -0,0 +1,2 @@
|
||||
max_width = 120
|
||||
use_small_heuristics = "Max"
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
//! `mailmig.toml`: where the servers are and how to log in to them.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub source: Endpoint,
|
||||
pub destination: Endpoint,
|
||||
/// Stalwart management API (used by `inventory`).
|
||||
pub stalwart: Option<StalwartApi>,
|
||||
#[serde(default)]
|
||||
pub folders: FolderCfg,
|
||||
#[serde(default)]
|
||||
pub sync: SyncCfg,
|
||||
#[serde(default)]
|
||||
pub snm: SnmCfg,
|
||||
pub dns: Option<DnsCfg>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TlsMode {
|
||||
#[default]
|
||||
Implicit,
|
||||
Starttls,
|
||||
/// Plain text, no TLS. Only for tests on a private network.
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Endpoint {
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub tls: TlsMode,
|
||||
/// PEM file with extra CA certificates to trust (instead of the system store).
|
||||
pub ca_file: Option<PathBuf>,
|
||||
/// Name to verify the certificate against, if it differs from `host`
|
||||
/// (e.g. connecting by IP before DNS is switched).
|
||||
pub tls_server_name: Option<String>,
|
||||
pub auth: Auth,
|
||||
/// Seconds to wait for a single server response.
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port.unwrap_or(match self.tls {
|
||||
TlsMode::Implicit => 993,
|
||||
_ => 143,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// How mailmig logs in as each account.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "mode", rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub enum Auth {
|
||||
/// Master user / impersonation via a composite login name.
|
||||
/// Stalwart: `login_format = "{user}%{master}"` (master needs the
|
||||
/// `impersonate` permission). Dovecot: `"{user}*{master}"`.
|
||||
Master {
|
||||
user: String,
|
||||
password: Option<String>,
|
||||
password_env: Option<String>,
|
||||
password_file: Option<PathBuf>,
|
||||
login_format: String,
|
||||
},
|
||||
/// SASL PLAIN with an authorization identity (authzid = target account,
|
||||
/// authcid = master). Supported by Dovecot master users.
|
||||
SaslAuthzid { user: String, password: Option<String>, password_env: Option<String>, password_file: Option<PathBuf> },
|
||||
/// Each account's own password: looked up in `passwords_file`
|
||||
/// (`login:password` per line) and then in the inventory secrets
|
||||
/// (plaintext passwords found on the source).
|
||||
Password { passwords_file: Option<PathBuf> },
|
||||
}
|
||||
|
||||
impl Auth {
|
||||
pub fn master_secret(&self) -> Option<SecretRef> {
|
||||
match self {
|
||||
Auth::Master { password, password_env, password_file, .. }
|
||||
| Auth::SaslAuthzid { password, password_env, password_file, .. } => Some(SecretRef {
|
||||
password: password.clone(),
|
||||
password_env: password_env.clone(),
|
||||
password_file: password_file.clone(),
|
||||
}),
|
||||
Auth::Password { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A secret given inline, via environment variable, or via file.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SecretRef {
|
||||
pub password: Option<String>,
|
||||
pub password_env: Option<String>,
|
||||
pub password_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl SecretRef {
|
||||
pub fn resolve(&self, what: &str) -> Result<String> {
|
||||
if let Some(v) = &self.password_env {
|
||||
return std::env::var(v).with_context(|| format!("{what}: environment variable {v} is not set"));
|
||||
}
|
||||
if let Some(f) = &self.password_file {
|
||||
let s = std::fs::read_to_string(f).with_context(|| format!("{what}: reading {}", f.display()))?;
|
||||
return Ok(s.lines().next().unwrap_or("").to_string());
|
||||
}
|
||||
if let Some(p) = &self.password {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
bail!("{what}: set one of password_env, password_file or password")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ApiKind {
|
||||
#[default]
|
||||
Auto,
|
||||
/// Stalwart <= 0.15: REST `/api/principal`.
|
||||
Rest,
|
||||
/// Stalwart >= 0.16: JMAP `x:Account` objects at `/api`.
|
||||
Jmap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StalwartApi {
|
||||
/// Base URL, e.g. `https://mail.b4l.co.th`.
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub kind: ApiKind,
|
||||
/// Basic-auth user (an admin account).
|
||||
pub user: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub password_env: Option<String>,
|
||||
pub password_file: Option<PathBuf>,
|
||||
/// Bearer token (API key) instead of user/password.
|
||||
pub token_env: Option<String>,
|
||||
}
|
||||
|
||||
impl StalwartApi {
|
||||
pub fn secret(&self) -> SecretRef {
|
||||
SecretRef {
|
||||
password: self.password.clone(),
|
||||
password_env: self.password_env.clone(),
|
||||
password_file: self.password_file.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FolderCfg {
|
||||
/// Explicit source → destination folder names (decoded, with the
|
||||
/// source/destination hierarchy separators respectively).
|
||||
#[serde(default)]
|
||||
pub map: BTreeMap<String, String>,
|
||||
/// Source folders to skip. `Name` matches exactly, `Name/*` matches the
|
||||
/// folder and everything below it (using the source separator).
|
||||
#[serde(default)]
|
||||
pub exclude: Vec<String>,
|
||||
/// Replacement for the destination hierarchy separator when it occurs
|
||||
/// inside a source folder name (e.g. "v1.2" on a "." server).
|
||||
#[serde(default = "default_sep_repl")]
|
||||
pub separator_replacement: String,
|
||||
/// Destination names for special-use folders that do not exist yet.
|
||||
#[serde(default = "default_special")]
|
||||
pub special: BTreeMap<String, String>,
|
||||
/// Subscribe every folder mailmig creates on the destination.
|
||||
#[serde(default = "yes")]
|
||||
pub subscribe: bool,
|
||||
}
|
||||
|
||||
impl Default for FolderCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
map: BTreeMap::new(),
|
||||
exclude: Vec::new(),
|
||||
separator_replacement: default_sep_repl(),
|
||||
special: default_special(),
|
||||
subscribe: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_sep_repl() -> String {
|
||||
"_".into()
|
||||
}
|
||||
|
||||
/// SNM's default mailbox names (`mailserver.mailboxes`) plus Archive.
|
||||
fn default_special() -> BTreeMap<String, String> {
|
||||
[("\\Sent", "Sent"), ("\\Trash", "Trash"), ("\\Junk", "Junk"), ("\\Drafts", "Drafts"), ("\\Archive", "Archive")]
|
||||
.into_iter()
|
||||
.map(|(a, b)| (a.to_string(), b.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SyncCfg {
|
||||
/// Accounts migrated in parallel (two IMAP connections each).
|
||||
#[serde(default = "default_jobs")]
|
||||
pub jobs: usize,
|
||||
/// Don't copy messages flagged \Deleted on the source.
|
||||
#[serde(default = "yes")]
|
||||
pub skip_deleted: bool,
|
||||
/// Max messages per body FETCH batch.
|
||||
#[serde(default = "default_batch")]
|
||||
pub batch_messages: usize,
|
||||
/// Max bytes per body FETCH batch.
|
||||
#[serde(default = "default_batch_bytes")]
|
||||
pub batch_bytes: u64,
|
||||
/// Retries per account after a connection error (sync is idempotent).
|
||||
#[serde(default = "default_retries")]
|
||||
pub retries: u32,
|
||||
}
|
||||
|
||||
impl Default for SyncCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
jobs: default_jobs(),
|
||||
skip_deleted: true,
|
||||
batch_messages: default_batch(),
|
||||
batch_bytes: default_batch_bytes(),
|
||||
retries: default_retries(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_jobs() -> usize {
|
||||
4
|
||||
}
|
||||
fn default_batch() -> usize {
|
||||
50
|
||||
}
|
||||
fn default_batch_bytes() -> u64 {
|
||||
32 * 1024 * 1024
|
||||
}
|
||||
fn default_retries() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
/// How `mailmig users` renders the destination side.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SnmCfg {
|
||||
/// Clan machine running SNM (for the `clan vars set` script).
|
||||
#[serde(default = "default_machine")]
|
||||
pub clan_machine: String,
|
||||
/// Clan vars generator name prefix; `@`→`-at-`, `.`→`-` is appended
|
||||
/// (matches modules/mail.nix in cnx-network-clan).
|
||||
#[serde(default = "default_gen_prefix")]
|
||||
pub clan_generator_prefix: String,
|
||||
/// Login of the temporary Dovecot master user (see dovecot-master-user.nix).
|
||||
#[serde(default = "default_master")]
|
||||
pub master_user: String,
|
||||
}
|
||||
|
||||
impl Default for SnmCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
clan_machine: default_machine(),
|
||||
clan_generator_prefix: default_gen_prefix(),
|
||||
master_user: default_master(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_machine() -> String {
|
||||
"mx1".into()
|
||||
}
|
||||
fn default_gen_prefix() -> String {
|
||||
"mail-passwd-".into()
|
||||
}
|
||||
fn default_master() -> String {
|
||||
"mailmig".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DnsCfg {
|
||||
/// Domains to manage; defaults to every domain in the plan.
|
||||
#[serde(default)]
|
||||
pub domains: Vec<String>,
|
||||
/// `cloudflare` (API) or `zonefile` (print records only).
|
||||
#[serde(default = "default_provider")]
|
||||
pub provider: String,
|
||||
#[serde(default = "default_cf_env")]
|
||||
pub api_token_env: String,
|
||||
#[serde(default = "default_cf_base")]
|
||||
pub api_base: String,
|
||||
/// MX targets as "priority host".
|
||||
pub mx: Vec<String>,
|
||||
#[serde(default = "default_spf")]
|
||||
pub spf: String,
|
||||
pub dmarc: Option<String>,
|
||||
#[serde(default = "default_selector")]
|
||||
pub dkim_selector: String,
|
||||
/// Directory with SNM's `<domain>.<selector>.txt` DKIM public keys
|
||||
/// (copied from the destination's /var/dkim).
|
||||
#[serde(default = "default_dkim_dir")]
|
||||
pub dkim_dir: PathBuf,
|
||||
/// Hostnames of the old server; SRV/CNAME records pointing there are
|
||||
/// retargeted or reported.
|
||||
#[serde(default)]
|
||||
pub old_hosts: Vec<String>,
|
||||
/// Client-facing IMAP/submission host for RFC 6186 SRV records.
|
||||
pub client_host: Option<String>,
|
||||
#[serde(default = "yes")]
|
||||
pub srv: bool,
|
||||
/// TTL for records mailmig writes (Cloudflare: 1 = auto).
|
||||
#[serde(default = "default_ttl")]
|
||||
pub ttl: u32,
|
||||
/// DNS-over-HTTPS JSON endpoint for `dns verify`.
|
||||
#[serde(default = "default_doh")]
|
||||
pub doh_url: String,
|
||||
/// Per-domain overrides of spf / dmarc / mx.
|
||||
#[serde(default)]
|
||||
pub overrides: BTreeMap<String, DnsOverride>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DnsOverride {
|
||||
pub spf: Option<String>,
|
||||
pub dmarc: Option<String>,
|
||||
pub mx: Option<Vec<String>>,
|
||||
/// Skip DKIM for this domain (e.g. key not generated yet).
|
||||
#[serde(default)]
|
||||
pub skip_dkim: bool,
|
||||
}
|
||||
|
||||
fn default_provider() -> String {
|
||||
"cloudflare".into()
|
||||
}
|
||||
fn default_cf_env() -> String {
|
||||
"CLOUDFLARE_API_TOKEN".into()
|
||||
}
|
||||
fn default_cf_base() -> String {
|
||||
"https://api.cloudflare.com/client/v4".into()
|
||||
}
|
||||
fn default_spf() -> String {
|
||||
"v=spf1 mx -all".into()
|
||||
}
|
||||
fn default_selector() -> String {
|
||||
"mail".into()
|
||||
}
|
||||
fn default_dkim_dir() -> PathBuf {
|
||||
"dkim".into()
|
||||
}
|
||||
fn default_ttl() -> u32 {
|
||||
300
|
||||
}
|
||||
fn default_doh() -> String {
|
||||
"https://cloudflare-dns.com/dns-query".into()
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Config> {
|
||||
let s = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
pub const EXAMPLE: &str = include_str!("../mailmig.example.toml");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn example_parses() {
|
||||
let c: Config = toml::from_str(EXAMPLE).unwrap();
|
||||
assert!(matches!(c.source.auth, Auth::Master { .. }));
|
||||
assert!(matches!(c.destination.auth, Auth::Master { .. }));
|
||||
assert_eq!(c.destination.port(), 993);
|
||||
assert_eq!(c.folders.special["\\Trash"], "Trash");
|
||||
assert!(c.dns.unwrap().mx[0].contains("mx1.cnx.email"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Cloudflare DNS API v4 (zones, dns_records).
|
||||
|
||||
use super::{Change, Rec, norm_host, norm_txt};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub struct Cloudflare {
|
||||
http: reqwest::Client,
|
||||
base: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Cloudflare {
|
||||
pub fn new(base: &str, token: String) -> Result<Cloudflare> {
|
||||
Ok(Cloudflare { http: crate::http_client()?, base: base.trim_end_matches('/').to_string(), token })
|
||||
}
|
||||
|
||||
async fn call(&self, method: reqwest::Method, path: &str, body: Option<Value>) -> Result<Value> {
|
||||
let mut rb = self.http.request(method.clone(), format!("{}{}", self.base, path)).bearer_auth(&self.token);
|
||||
if let Some(b) = body {
|
||||
rb = rb.json(&b);
|
||||
}
|
||||
let resp = rb.send().await.with_context(|| format!("Cloudflare {method} {path}"))?;
|
||||
let status = resp.status();
|
||||
let v: Value = resp.json().await.with_context(|| format!("Cloudflare {method} {path}: bad JSON"))?;
|
||||
if !status.is_success() || v.get("success").and_then(|s| s.as_bool()) != Some(true) {
|
||||
bail!("Cloudflare {method} {path}: HTTP {status}: {}", v.get("errors").unwrap_or(&v));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub async fn zone_id(&self, domain: &str) -> Result<String> {
|
||||
let v = self.call(reqwest::Method::GET, &format!("/zones?name={}", norm_host(domain)), None).await?;
|
||||
v["result"]
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|z| z["id"].as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| anyhow!("no Cloudflare zone for {domain} visible to this token"))
|
||||
}
|
||||
|
||||
pub async fn list(&self, zone: &str) -> Result<Vec<Rec>> {
|
||||
let mut out = Vec::new();
|
||||
let mut page = 1;
|
||||
loop {
|
||||
let v = self
|
||||
.call(reqwest::Method::GET, &format!("/zones/{zone}/dns_records?per_page=500&page={page}"), None)
|
||||
.await?;
|
||||
for r in v["result"].as_array().cloned().unwrap_or_default() {
|
||||
if let Some(rec) = from_cf(&r) {
|
||||
out.push(rec);
|
||||
}
|
||||
}
|
||||
let total_pages = v["result_info"]["total_pages"].as_u64().unwrap_or(1);
|
||||
if page as u64 >= total_pages {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn create(&self, zone: &str, r: &Rec) -> Result<String> {
|
||||
let v = self.call(reqwest::Method::POST, &format!("/zones/{zone}/dns_records"), Some(to_cf(r))).await?;
|
||||
v["result"]["id"].as_str().map(String::from).ok_or_else(|| anyhow!("Cloudflare create: no id returned"))
|
||||
}
|
||||
|
||||
pub async fn update(&self, zone: &str, id: &str, r: &Rec) -> Result<()> {
|
||||
self.call(reqwest::Method::PUT, &format!("/zones/{zone}/dns_records/{id}"), Some(to_cf(r))).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(&self, zone: &str, id: &str) -> Result<()> {
|
||||
self.call(reqwest::Method::DELETE, &format!("/zones/{zone}/dns_records/{id}"), None).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply changes in order (creates first, deletes last, so a slot is never
|
||||
/// empty). Returns the journal of what was done, for rollback.
|
||||
pub async fn apply(&self, zone: &str, changes: &[Change]) -> Result<Vec<Applied>> {
|
||||
let mut done = Vec::new();
|
||||
let rank = |c: &Change| match c {
|
||||
Change::Create { .. } => 0,
|
||||
Change::Update { .. } => 1,
|
||||
Change::Delete { .. } => 2,
|
||||
};
|
||||
let mut ordered: Vec<&Change> = changes.iter().collect();
|
||||
ordered.sort_by_key(|c| rank(c));
|
||||
for c in ordered {
|
||||
let r = match c {
|
||||
Change::Create { new } => {
|
||||
self.create(zone, new).await.map(|id| Applied::Created { id, rec: new.clone() })
|
||||
}
|
||||
Change::Update { old, new } => {
|
||||
let id = old.id.clone().ok_or_else(|| anyhow!("update without record id"))?;
|
||||
self.update(zone, &id, new).await.map(|_| Applied::Updated {
|
||||
id,
|
||||
old: old.clone(),
|
||||
new: new.clone(),
|
||||
})
|
||||
}
|
||||
Change::Delete { old, .. } => {
|
||||
let id = old.id.clone().ok_or_else(|| anyhow!("delete without record id"))?;
|
||||
self.delete(zone, &id).await.map(|_| Applied::Deleted { old: old.clone() })
|
||||
}
|
||||
};
|
||||
match r {
|
||||
Ok(a) => {
|
||||
tracing::info!(" applied {}", c.display().lines().next().unwrap_or(""));
|
||||
done.push(a)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(" FAILED {}: {e:#}", c.display().lines().next().unwrap_or(""));
|
||||
return Err(ApplyError { done, source: e }.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
|
||||
/// Undo a journal in reverse order.
|
||||
pub async fn rollback(&self, zone: &str, journal: &[Applied]) -> Result<()> {
|
||||
for a in journal.iter().rev() {
|
||||
match a {
|
||||
Applied::Created { id, rec } => {
|
||||
self.delete(zone, id).await?;
|
||||
tracing::info!(" removed {}", rec.display());
|
||||
}
|
||||
Applied::Updated { id, old, .. } => {
|
||||
self.update(zone, id, old).await?;
|
||||
tracing::info!(" restored {}", old.display());
|
||||
}
|
||||
Applied::Deleted { old } => {
|
||||
self.create(zone, old).await?;
|
||||
tracing::info!(" re-created {}", old.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "lowercase")]
|
||||
pub enum Applied {
|
||||
Created { id: String, rec: Rec },
|
||||
Updated { id: String, old: Rec, new: Rec },
|
||||
Deleted { old: Rec },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApplyError {
|
||||
pub done: Vec<Applied>,
|
||||
pub source: anyhow::Error,
|
||||
}
|
||||
impl std::fmt::Display for ApplyError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:#} (after {} successful changes)", self.source, self.done.len())
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ApplyError {}
|
||||
|
||||
fn from_cf(r: &Value) -> Option<Rec> {
|
||||
let rtype = r["type"].as_str()?.to_string();
|
||||
let name = norm_host(r["name"].as_str()?);
|
||||
let ttl = r["ttl"].as_u64().unwrap_or(1) as u32;
|
||||
let id = r["id"].as_str().map(String::from);
|
||||
let (content, priority) = match rtype.as_str() {
|
||||
"MX" => (norm_host(r["content"].as_str()?), r["priority"].as_u64().map(|p| p as u16)),
|
||||
"TXT" => (norm_txt(r["content"].as_str()?), None),
|
||||
"SRV" => {
|
||||
let d = &r["data"];
|
||||
if d.is_object() {
|
||||
(
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
d["priority"].as_u64().unwrap_or(0),
|
||||
d["weight"].as_u64().unwrap_or(0),
|
||||
d["port"].as_u64().unwrap_or(0),
|
||||
norm_host(d["target"].as_str().unwrap_or(""))
|
||||
),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
// "weight port target" with separate priority
|
||||
let c = r["content"].as_str()?;
|
||||
let p = r["priority"].as_u64().unwrap_or(0);
|
||||
(format!("{p} {}", norm_host(c)), None)
|
||||
}
|
||||
}
|
||||
"CNAME" | "NS" => (norm_host(r["content"].as_str()?), None),
|
||||
_ => (r["content"].as_str().unwrap_or("").to_string(), None),
|
||||
};
|
||||
Some(Rec { name, rtype, content, priority, ttl, id })
|
||||
}
|
||||
|
||||
fn to_cf(r: &Rec) -> Value {
|
||||
match r.rtype.as_str() {
|
||||
"MX" => {
|
||||
json!({"type": "MX", "name": r.name, "content": r.content, "priority": r.priority.unwrap_or(10), "ttl": r.ttl})
|
||||
}
|
||||
"SRV" => {
|
||||
let p: Vec<&str> = r.content.split(' ').collect();
|
||||
let num = |i: usize| p.get(i).and_then(|x| x.parse::<u64>().ok()).unwrap_or(0);
|
||||
json!({"type": "SRV", "name": r.name, "ttl": r.ttl, "data": {
|
||||
"priority": num(0), "weight": num(1), "port": num(2), "target": p.get(3).copied().unwrap_or("")
|
||||
}})
|
||||
}
|
||||
"TXT" => json!({"type": "TXT", "name": r.name, "content": quote_txt(&r.content), "ttl": r.ttl}),
|
||||
t => json!({"type": t, "name": r.name, "content": r.content, "ttl": r.ttl}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Quoted TXT content, split into 255-byte strings (Cloudflare accepts both
|
||||
/// forms; quoting explicitly keeps long DKIM keys intact).
|
||||
fn quote_txt(s: &str) -> String {
|
||||
s.as_bytes()
|
||||
.chunks(255)
|
||||
.map(|c| format!("\"{}\"", String::from_utf8_lossy(c).replace('\\', "\\\\").replace('"', "\\\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::DnsCfg;
|
||||
use crate::dns::{desired_records, plan_domain};
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
|
||||
fn ok(result: Value) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200).set_body_json(json!({"success": true, "errors": [], "result": result,
|
||||
"result_info": {"page": 1, "total_pages": 1}}))
|
||||
}
|
||||
|
||||
struct EchoCreate;
|
||||
impl Respond for EchoCreate {
|
||||
fn respond(&self, req: &Request) -> ResponseTemplate {
|
||||
let mut body: Value = serde_json::from_slice(&req.body).unwrap();
|
||||
body["id"] = json!(format!("new-{}", body["name"].as_str().unwrap()));
|
||||
ok(body)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloudflare_end_to_end() {
|
||||
crate::init_crypto();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/zones"))
|
||||
.and(query_param("name", "b4l.co.th"))
|
||||
.and(header("authorization", "Bearer tok"))
|
||||
.respond_with(ok(json!([{"id": "z1", "name": "b4l.co.th"}])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/zones/z1/dns_records"))
|
||||
.respond_with(ok(json!([
|
||||
{"id": "r1", "type": "MX", "name": "b4l.co.th", "content": "mail.b4l.co.th", "priority": 10, "ttl": 1},
|
||||
{"id": "r2", "type": "TXT", "name": "b4l.co.th", "content": "\"v=spf1 mx -all\"", "ttl": 1},
|
||||
{"id": "r3", "type": "SRV", "name": "_imaps._tcp.b4l.co.th", "content": "1 993 mail.b4l.co.th", "priority": 0,
|
||||
"data": {"priority": 0, "weight": 1, "port": 993, "target": "mail.b4l.co.th"}, "ttl": 1},
|
||||
{"id": "r4", "type": "A", "name": "www.b4l.co.th", "content": "1.2.3.4", "ttl": 1}
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST")).and(path("/zones/z1/dns_records")).respond_with(EchoCreate).mount(&server).await;
|
||||
Mock::given(method("PUT")).respond_with(ok(json!({}))).mount(&server).await;
|
||||
Mock::given(method("DELETE")).respond_with(ok(json!({}))).mount(&server).await;
|
||||
|
||||
let cf = Cloudflare::new(&server.uri(), "tok".into()).unwrap();
|
||||
let zone = cf.zone_id("b4l.co.th").await.unwrap();
|
||||
assert_eq!(zone, "z1");
|
||||
let current = cf.list(&zone).await.unwrap();
|
||||
assert_eq!(current.len(), 4);
|
||||
assert_eq!(current[1].content, "v=spf1 mx -all");
|
||||
assert_eq!(current[2].content, "0 1 993 mail.b4l.co.th");
|
||||
|
||||
let cfg: DnsCfg = toml::from_str(
|
||||
"mx = [\"10 mx1.cnx.email\"]\nold_hosts=[\"mail.b4l.co.th\"]\nclient_host=\"mail.cnx.email\"\ndmarc=\"v=DMARC1; p=none\"",
|
||||
)
|
||||
.unwrap();
|
||||
let want = desired_records(&cfg, "b4l.co.th", Some("v=DKIM1; k=rsa; p=K")).unwrap();
|
||||
let plan = plan_domain(&cfg, "b4l.co.th", want, ¤t);
|
||||
// MX + SRV _imaps updated; dmarc, dkim, 2 SRV created; SPF unchanged.
|
||||
let n_upd = plan.changes.iter().filter(|c| matches!(c, Change::Update { .. })).count();
|
||||
let n_new = plan.changes.iter().filter(|c| matches!(c, Change::Create { .. })).count();
|
||||
assert_eq!((n_upd, n_new), (2, 4), "{:#?}", plan.changes);
|
||||
|
||||
let journal = cf.apply(&zone, &plan.changes).await.unwrap();
|
||||
assert_eq!(journal.len(), 6);
|
||||
let reqs = server.received_requests().await.unwrap();
|
||||
let mx_put = reqs.iter().find(|r| r.method.as_str() == "PUT" && r.url.path().ends_with("/r1")).unwrap();
|
||||
let body: Value = serde_json::from_slice(&mx_put.body).unwrap();
|
||||
assert_eq!(body["content"], "mx1.cnx.email");
|
||||
assert_eq!(body["priority"], 10);
|
||||
let srv_post = reqs
|
||||
.iter()
|
||||
.filter(|r| r.method.as_str() == "POST")
|
||||
.map(|r| serde_json::from_slice::<Value>(&r.body).unwrap())
|
||||
.find(|b| b["type"] == "SRV")
|
||||
.unwrap();
|
||||
assert_eq!(srv_post["data"]["target"], "mail.cnx.email");
|
||||
|
||||
cf.rollback(&zone, &journal).await.unwrap();
|
||||
let reqs = server.received_requests().await.unwrap();
|
||||
let deletes = reqs.iter().filter(|r| r.method.as_str() == "DELETE").count();
|
||||
assert_eq!(deletes, 4, "rollback deletes the 4 created records");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_quoting() {
|
||||
let long = "x".repeat(300);
|
||||
let q = quote_txt(&long);
|
||||
assert!(q.starts_with('"') && q.contains("\" \""));
|
||||
assert_eq!(norm_txt(&q), long);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Check what the world sees, via DNS-over-HTTPS (JSON API).
|
||||
|
||||
use super::{Rec, norm_host, norm_txt};
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
pub struct Doh {
|
||||
http: reqwest::Client,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl Doh {
|
||||
pub fn new(url: &str) -> Result<Doh> {
|
||||
Ok(Doh { http: crate::http_client()?, url: url.to_string() })
|
||||
}
|
||||
|
||||
/// Answers of `rtype` for `name`, normalised like provider records.
|
||||
pub async fn query(&self, name: &str, rtype: &str) -> Result<Vec<Rec>> {
|
||||
let v: Value = self
|
||||
.http
|
||||
.get(&self.url)
|
||||
.query(&[("name", name), ("type", rtype)])
|
||||
.header("Accept", "application/dns-json")
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("DoH {name} {rtype}"))?
|
||||
.json()
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
for a in v["Answer"].as_array().cloned().unwrap_or_default() {
|
||||
let t = match a["type"].as_u64() {
|
||||
Some(15) => "MX",
|
||||
Some(16) => "TXT",
|
||||
Some(33) => "SRV",
|
||||
Some(5) => "CNAME",
|
||||
_ => continue,
|
||||
};
|
||||
if t != rtype {
|
||||
continue;
|
||||
}
|
||||
let data = a["data"].as_str().unwrap_or("");
|
||||
let ttl = a["TTL"].as_u64().unwrap_or(0) as u32;
|
||||
let (content, prio) = match t {
|
||||
"MX" => {
|
||||
let (p, h) = data.split_once(' ').unwrap_or(("0", data));
|
||||
(norm_host(h), p.parse().ok())
|
||||
}
|
||||
"TXT" => (norm_txt(data), None),
|
||||
"SRV" => {
|
||||
let parts: Vec<&str> = data.split_whitespace().collect();
|
||||
let target = parts.last().map(|h| norm_host(h)).unwrap_or_default();
|
||||
(format!("{} {target}", parts[..parts.len().saturating_sub(1)].join(" ")), None)
|
||||
}
|
||||
_ => (norm_host(data), None),
|
||||
};
|
||||
out.push(Rec::new(&norm_host(a["name"].as_str().unwrap_or(name)), t, &content, prio, ttl));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// For each desired record: is an identical one publicly visible?
|
||||
pub async fn verify(&self, desired: &[Rec]) -> Result<Vec<(Rec, bool, Vec<Rec>)>> {
|
||||
let mut out = Vec::new();
|
||||
for r in desired {
|
||||
let seen = self.query(&r.name, &r.rtype).await?;
|
||||
let ok = seen.iter().any(|s| s.same(r));
|
||||
out.push((r.clone(), ok, seen));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
//! Mail DNS records: compute the desired set per domain, diff it against
|
||||
//! what is published, and apply via Cloudflare (or print zone-file lines).
|
||||
|
||||
pub mod cloudflare;
|
||||
pub mod doh;
|
||||
|
||||
use crate::config::DnsCfg;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Rec {
|
||||
/// FQDN, lowercase, no trailing dot.
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub rtype: String,
|
||||
/// Normalised content: MX/CNAME target host; TXT unquoted joined text;
|
||||
/// SRV "priority weight port target".
|
||||
pub content: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub ttl: u32,
|
||||
/// Provider record id (current records only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
impl Rec {
|
||||
pub fn new(name: &str, rtype: &str, content: &str, priority: Option<u16>, ttl: u32) -> Rec {
|
||||
Rec { name: norm_host(name), rtype: rtype.to_string(), content: content.to_string(), priority, ttl, id: None }
|
||||
}
|
||||
/// Same data (ignoring TTL and id).
|
||||
pub fn same(&self, o: &Rec) -> bool {
|
||||
self.name == o.name
|
||||
&& self.rtype == o.rtype
|
||||
&& self.priority == o.priority
|
||||
&& if self.rtype == "TXT" {
|
||||
self.content == o.content
|
||||
} else {
|
||||
self.content.eq_ignore_ascii_case(&o.content)
|
||||
}
|
||||
}
|
||||
pub fn display(&self) -> String {
|
||||
match self.priority {
|
||||
Some(p) => format!("{} {} {} {}", self.name, self.rtype, p, self.content),
|
||||
None if self.rtype == "TXT" => format!("{} TXT \"{}\"", self.name, short(&self.content, 90)),
|
||||
None => format!("{} {} {}", self.name, self.rtype, self.content),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn short(s: &str, n: usize) -> String {
|
||||
if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n).collect::<String>()) }
|
||||
}
|
||||
|
||||
pub fn norm_host(h: &str) -> String {
|
||||
h.trim().trim_end_matches('.').to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// TXT content as published may be quoted and split into 255-byte strings
|
||||
/// (`"v=DKIM1; k=rsa; " "p=MIIB..."`); join it back into one value.
|
||||
pub fn norm_txt(s: &str) -> String {
|
||||
let t = s.trim();
|
||||
if !t.starts_with('"') {
|
||||
return t.to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut chars = t.chars();
|
||||
let mut inq = false;
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => inq = !inq,
|
||||
'\\' if inq => {
|
||||
if let Some(n) = chars.next() {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
c if inq => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse SNM's `/var/dkim/<domain>.<selector>.txt` (rspamadm dkim_keygen
|
||||
/// output: `mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " "p=..." ) ;`).
|
||||
pub fn parse_dkim_file(s: &str) -> Result<String> {
|
||||
let mut out = String::new();
|
||||
let mut inq = false;
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => inq = !inq,
|
||||
c if inq => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let out = out.trim().to_string();
|
||||
if !out.starts_with("v=DKIM1") || !out.contains("p=") {
|
||||
bail!("not a DKIM TXT record");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn load_dkim(dir: &Path, domain: &str, selector: &str) -> Result<String> {
|
||||
let path = dir.join(format!("{domain}.{selector}.txt"));
|
||||
let s = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!("reading {} (copy it from the SNM host: /var/dkim/{domain}.{selector}.txt)", path.display())
|
||||
})?;
|
||||
parse_dkim_file(&s).with_context(|| format!("parsing {}", path.display()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "op", rename_all = "lowercase")]
|
||||
pub enum Change {
|
||||
Create { new: Rec },
|
||||
Update { old: Rec, new: Rec },
|
||||
Delete { old: Rec, reason: String },
|
||||
}
|
||||
|
||||
impl Change {
|
||||
pub fn display(&self) -> String {
|
||||
match self {
|
||||
Change::Create { new } => format!("+ {}", new.display()),
|
||||
Change::Update { old, new } => format!("~ {}\n was {}", new.display(), old.display()),
|
||||
Change::Delete { old, reason } => format!("- {} ({reason})", old.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct DomainPlan {
|
||||
pub domain: String,
|
||||
pub desired: Vec<Rec>,
|
||||
pub changes: Vec<Change>,
|
||||
pub unchanged: Vec<Rec>,
|
||||
pub warnings: Vec<String>,
|
||||
pub info: Vec<String>,
|
||||
}
|
||||
|
||||
const MAIL_SRV: &[&str] = &[
|
||||
"_imap._tcp",
|
||||
"_imaps._tcp",
|
||||
"_pop3._tcp",
|
||||
"_pop3s._tcp",
|
||||
"_submission._tcp",
|
||||
"_submissions._tcp",
|
||||
"_jmap._tcp",
|
||||
"_caldav._tcp",
|
||||
"_caldavs._tcp",
|
||||
"_carddav._tcp",
|
||||
"_carddavs._tcp",
|
||||
"_autodiscover._tcp",
|
||||
];
|
||||
|
||||
/// Records mailmig wants for `domain`.
|
||||
pub fn desired_records(cfg: &DnsCfg, domain: &str, dkim: Option<&str>) -> Result<Vec<Rec>> {
|
||||
let d = norm_host(domain);
|
||||
let ov = cfg.overrides.get(&d).cloned().unwrap_or_default();
|
||||
let ttl = cfg.ttl;
|
||||
let mut out = Vec::new();
|
||||
for mx in ov.mx.as_ref().unwrap_or(&cfg.mx) {
|
||||
let (p, host) = mx
|
||||
.split_once(char::is_whitespace)
|
||||
.with_context(|| format!("dns.mx entry '{mx}' must be 'priority host'"))?;
|
||||
let p: u16 = p.trim().parse().with_context(|| format!("bad MX priority in '{mx}'"))?;
|
||||
out.push(Rec::new(&d, "MX", &norm_host(host), Some(p), ttl));
|
||||
}
|
||||
out.push(Rec::new(&d, "TXT", ov.spf.as_deref().unwrap_or(&cfg.spf), None, ttl));
|
||||
if let Some(dmarc) = ov.dmarc.as_ref().or(cfg.dmarc.as_ref()) {
|
||||
out.push(Rec::new(&format!("_dmarc.{d}"), "TXT", dmarc, None, ttl));
|
||||
}
|
||||
if let Some(k) = dkim {
|
||||
out.push(Rec::new(&format!("{}._domainkey.{d}", cfg.dkim_selector), "TXT", k, None, ttl));
|
||||
}
|
||||
if cfg.srv
|
||||
&& let Some(h) = &cfg.client_host
|
||||
{
|
||||
let h = norm_host(h);
|
||||
out.push(Rec::new(&format!("_imaps._tcp.{d}"), "SRV", &format!("0 1 993 {h}"), None, ttl));
|
||||
out.push(Rec::new(&format!("_submissions._tcp.{d}"), "SRV", &format!("0 1 465 {h}"), None, ttl));
|
||||
out.push(Rec::new(&format!("_submission._tcp.{d}"), "SRV", &format!("0 1 587 {h}"), None, ttl));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Which "slot" a record belongs to: records in the same slot replace each other.
|
||||
fn slot(r: &Rec, domain: &str, selector: &str) -> Option<String> {
|
||||
let d = norm_host(domain);
|
||||
let lc = r.content.to_ascii_lowercase();
|
||||
match r.rtype.as_str() {
|
||||
"MX" if r.name == d => Some("mx".into()),
|
||||
"TXT" if r.name == d && lc.starts_with("v=spf1") => Some("spf".into()),
|
||||
"TXT" if r.name == format!("_dmarc.{d}") && lc.starts_with("v=dmarc1") => Some("dmarc".into()),
|
||||
"TXT" if r.name == format!("{selector}._domainkey.{d}") => Some("dkim".into()),
|
||||
"SRV" => r.name.strip_suffix(&format!(".{d}")).map(|svc| format!("srv:{svc}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff desired vs current for one domain. Records outside mailmig's slots
|
||||
/// are left alone, but anything still pointing at an old host is reported.
|
||||
pub fn plan_domain(cfg: &DnsCfg, domain: &str, desired: Vec<Rec>, current: &[Rec]) -> DomainPlan {
|
||||
let d = norm_host(domain);
|
||||
let mut plan = DomainPlan { domain: d.clone(), desired: desired.clone(), ..Default::default() };
|
||||
let old_hosts: Vec<String> = cfg.old_hosts.iter().map(|h| norm_host(h)).collect();
|
||||
let points_old = |r: &Rec| {
|
||||
let target = r.content.rsplit(' ').next().unwrap_or("");
|
||||
old_hosts.iter().any(|h| norm_host(target) == *h)
|
||||
};
|
||||
|
||||
let mut slots: Vec<String> = desired.iter().filter_map(|r| slot(r, &d, &cfg.dkim_selector)).collect();
|
||||
slots.sort();
|
||||
slots.dedup();
|
||||
|
||||
for s in &slots {
|
||||
let want: Vec<&Rec> = desired.iter().filter(|r| slot(r, &d, &cfg.dkim_selector).as_ref() == Some(s)).collect();
|
||||
let mut have: Vec<&Rec> =
|
||||
current.iter().filter(|r| slot(r, &d, &cfg.dkim_selector).as_ref() == Some(s)).collect();
|
||||
let mut todo: Vec<&Rec> = Vec::new();
|
||||
for w in want {
|
||||
if let Some(pos) = have.iter().position(|h| h.same(w)) {
|
||||
plan.unchanged.push(have.remove(pos).clone());
|
||||
} else {
|
||||
todo.push(w);
|
||||
}
|
||||
}
|
||||
// Reuse existing records (keeps ids, fewer API calls), then create/delete the rest.
|
||||
for w in todo {
|
||||
if !have.is_empty() {
|
||||
let old = have.remove(0).clone();
|
||||
plan.changes.push(Change::Update { old, new: w.clone() });
|
||||
} else {
|
||||
plan.changes.push(Change::Create { new: w.clone() });
|
||||
}
|
||||
}
|
||||
for h in have {
|
||||
plan.changes.push(Change::Delete { old: h.clone(), reason: format!("replaced ({s})") });
|
||||
}
|
||||
}
|
||||
|
||||
// Mail SRV records we don't publish but which point at the old server.
|
||||
for r in current {
|
||||
if r.rtype != "SRV" || slot(r, &d, &cfg.dkim_selector).is_some_and(|s| slots.contains(&s)) {
|
||||
continue;
|
||||
}
|
||||
let svc = r.name.strip_suffix(&format!(".{d}")).unwrap_or(&r.name);
|
||||
if MAIL_SRV.contains(&svc) && points_old(r) {
|
||||
plan.changes.push(Change::Delete {
|
||||
old: r.clone(),
|
||||
reason: "service not offered by SNM, points at old server".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for r in current {
|
||||
let in_changes = plan.changes.iter().any(|c| match c {
|
||||
Change::Update { old, .. } | Change::Delete { old, .. } => old.id == r.id && old.same(r),
|
||||
_ => false,
|
||||
});
|
||||
if in_changes {
|
||||
continue;
|
||||
}
|
||||
if r.name == format!("_mta-sts.{d}") && r.rtype == "TXT" {
|
||||
plan.warnings.push(format!(
|
||||
"{d} publishes MTA-STS ({}). Senders cache the policy (max_age); if it lists only the old MX in \
|
||||
'enforce' mode they will refuse to deliver to the new MX. Before switching: publish a policy that \
|
||||
lists both MX hosts (or mode: testing), bump the id, and wait max_age.",
|
||||
r.content
|
||||
));
|
||||
} else if r.name.ends_with(&format!("._domainkey.{d}")) && r.rtype == "TXT" {
|
||||
plan.info.push(format!("old DKIM key {} kept (harmless; delete after the cutover)", r.name));
|
||||
} else if (r.rtype == "CNAME" || r.rtype == "MX" || r.rtype == "SRV") && points_old(r) {
|
||||
plan.warnings
|
||||
.push(format!("{} still points at the old server; it will dangle after decommissioning", r.display()));
|
||||
} else if r.name == format!("_smtp._tls.{d}") {
|
||||
plan.info.push(format!("TLS-RPT record kept: {}", r.content));
|
||||
}
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
/// Zone-file lines (Knot/BIND) for the desired records.
|
||||
pub fn render_zone(desired: &[Rec], origin: &str) -> String {
|
||||
let o = norm_host(origin);
|
||||
let rel = |n: &str| -> String {
|
||||
if n == o {
|
||||
"@".into()
|
||||
} else {
|
||||
n.strip_suffix(&format!(".{o}")).map(String::from).unwrap_or_else(|| format!("{n}."))
|
||||
}
|
||||
};
|
||||
let mut s = format!("; ---- Mail (mailmig) for {o} ----\n");
|
||||
for r in desired {
|
||||
let name = rel(&r.name);
|
||||
let line = match r.rtype.as_str() {
|
||||
"MX" => format!("{name:<24} IN MX {} {}.", r.priority.unwrap_or(10), r.content),
|
||||
"SRV" => {
|
||||
let mut parts: Vec<&str> = r.content.split(' ').collect();
|
||||
let target = format!("{}.", parts.pop().unwrap_or(""));
|
||||
format!("{name:<24} IN SRV {} {target}", parts.join(" "))
|
||||
}
|
||||
"TXT" => {
|
||||
let chunks: Vec<String> = r
|
||||
.content
|
||||
.as_bytes()
|
||||
.chunks(255)
|
||||
.map(|c| format!("\"{}\"", String::from_utf8_lossy(c).replace('"', "\\\"")))
|
||||
.collect();
|
||||
if chunks.len() == 1 {
|
||||
format!("{name:<24} IN TXT {}", chunks[0])
|
||||
} else {
|
||||
format!("{name:<24} IN TXT ( {} )", chunks.join("\n "))
|
||||
}
|
||||
}
|
||||
t => format!("{name:<24} IN {t} {}", r.content),
|
||||
};
|
||||
s.push_str(&line);
|
||||
s.push('\n');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> DnsCfg {
|
||||
toml::from_str(
|
||||
r#"
|
||||
mx = ["10 mx1.cnx.email"]
|
||||
dmarc = "v=DMARC1; p=quarantine"
|
||||
old_hosts = ["mail.b4l.co.th"]
|
||||
client_host = "mail.cnx.email"
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dkim_file() {
|
||||
let f = "mail._domainkey IN TXT ( \"v=DKIM1; k=rsa; \"\n\t\"p=MIIBIjAN\"\n\t\"BgkqhkiG\" ) ;\n";
|
||||
assert_eq!(parse_dkim_file(f).unwrap(), "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG");
|
||||
assert!(parse_dkim_file("garbage").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_normalisation() {
|
||||
assert_eq!(norm_txt("\"v=DKIM1; k=rsa; \" \"p=AB\""), "v=DKIM1; k=rsa; p=AB");
|
||||
assert_eq!(norm_txt("v=spf1 mx -all"), "v=spf1 mx -all");
|
||||
assert_eq!(norm_txt("\"a\\\"b\""), "a\"b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_against_stalwart_records() {
|
||||
let c = cfg();
|
||||
let want = desired_records(&c, "b4l.co.th", Some("v=DKIM1; k=rsa; p=NEW")).unwrap();
|
||||
let cur = |name: &str, t: &str, content: &str, prio: Option<u16>, id: &str| Rec {
|
||||
id: Some(id.into()),
|
||||
..Rec::new(name, t, content, prio, 1)
|
||||
};
|
||||
let current = vec![
|
||||
cur("b4l.co.th", "MX", "mail.b4l.co.th", Some(10), "1"),
|
||||
cur("b4l.co.th", "TXT", "v=spf1 mx ra=postmaster -all", None, "2"),
|
||||
cur("b4l.co.th", "TXT", "google-site-verification=abc", None, "3"),
|
||||
cur("_dmarc.b4l.co.th", "TXT", "v=DMARC1; p=quarantine", None, "4"),
|
||||
cur("202401e._domainkey.b4l.co.th", "TXT", "v=DKIM1; k=ed25519; p=OLD", None, "5"),
|
||||
cur("_imaps._tcp.b4l.co.th", "SRV", "0 1 993 mail.b4l.co.th", None, "6"),
|
||||
cur("_pop3s._tcp.b4l.co.th", "SRV", "0 1 995 mail.b4l.co.th", None, "7"),
|
||||
cur("_jmap._tcp.b4l.co.th", "SRV", "0 1 443 mail.b4l.co.th", None, "8"),
|
||||
cur("autoconfig.b4l.co.th", "CNAME", "mail.b4l.co.th", None, "9"),
|
||||
cur("_mta-sts.b4l.co.th", "TXT", "v=STSv1; id=123", None, "10"),
|
||||
];
|
||||
let p = plan_domain(&c, "b4l.co.th", want, ¤t);
|
||||
let shown: Vec<String> = p.changes.iter().map(|c| c.display()).collect();
|
||||
let text = shown.join("\n");
|
||||
assert!(text.contains("~ b4l.co.th MX 10 mx1.cnx.email"), "{text}");
|
||||
assert!(text.contains("~ b4l.co.th TXT \"v=spf1 mx -all\""), "{text}");
|
||||
assert!(text.contains("+ mail._domainkey.b4l.co.th TXT"), "{text}");
|
||||
assert!(text.contains("~ _imaps._tcp.b4l.co.th SRV 0 1 993 mail.cnx.email"), "{text}");
|
||||
assert!(text.contains("+ _submissions._tcp.b4l.co.th SRV 0 1 465 mail.cnx.email"), "{text}");
|
||||
assert!(text.contains("- _pop3s._tcp.b4l.co.th SRV"), "{text}");
|
||||
assert!(text.contains("- _jmap._tcp.b4l.co.th SRV"), "{text}");
|
||||
// DMARC already matches; google verification untouched
|
||||
assert!(p.unchanged.iter().any(|r| r.name == "_dmarc.b4l.co.th"));
|
||||
assert!(!text.contains("google"));
|
||||
assert!(p.warnings.iter().any(|w| w.contains("MTA-STS")));
|
||||
assert!(p.warnings.iter().any(|w| w.contains("autoconfig")));
|
||||
assert!(p.info.iter().any(|w| w.contains("202401e")));
|
||||
// Second run against the applied state is a no-op.
|
||||
let applied: Vec<Rec> = p
|
||||
.unchanged
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(p.changes.iter().filter_map(|c| match c {
|
||||
Change::Create { new } | Change::Update { new, .. } => Some(new.clone()),
|
||||
_ => None,
|
||||
}))
|
||||
.collect();
|
||||
let want = desired_records(&c, "b4l.co.th", Some("v=DKIM1; k=rsa; p=NEW")).unwrap();
|
||||
assert!(plan_domain(&c, "b4l.co.th", want, &applied).changes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_output() {
|
||||
let c = cfg();
|
||||
let long = format!("v=DKIM1; k=rsa; p={}", "A".repeat(400));
|
||||
let want = desired_records(&c, "poyrecords.com", Some(&long)).unwrap();
|
||||
let z = render_zone(&want, "poyrecords.com");
|
||||
assert!(z.contains("@ IN MX 10 mx1.cnx.email."));
|
||||
assert!(z.contains("_dmarc IN TXT \"v=DMARC1; p=quarantine\""));
|
||||
assert!(z.contains("mail._domainkey IN TXT ( \""));
|
||||
assert!(z.contains("_imaps._tcp IN SRV 0 1 993 mail.cnx.email."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
//! Minimal async IMAP4rev1/rev2 client: just what a migration needs.
|
||||
|
||||
use super::proto::{self, Arg, Kind, Part, Response, Status, Value};
|
||||
use super::utf7;
|
||||
use crate::config::{Endpoint, TlsMode};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use base64::Engine;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufStream};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
pub trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
|
||||
|
||||
pub struct Client {
|
||||
io: BufStream<Box<dyn Stream>>,
|
||||
tag: u32,
|
||||
pub caps: HashSet<String>,
|
||||
pub label: String,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Mailbox {
|
||||
/// Name exactly as on the wire (modified UTF-7).
|
||||
pub wire: String,
|
||||
/// Decoded Unicode name.
|
||||
pub name: String,
|
||||
pub delim: Option<char>,
|
||||
/// Attributes, e.g. `\Noselect`, `\Sent` (as sent by the server).
|
||||
pub attrs: Vec<String>,
|
||||
}
|
||||
|
||||
impl Mailbox {
|
||||
pub fn has_attr(&self, a: &str) -> bool {
|
||||
self.attrs.iter().any(|x| x.eq_ignore_ascii_case(a))
|
||||
}
|
||||
pub fn selectable(&self) -> bool {
|
||||
!self.has_attr("\\Noselect") && !self.has_attr("\\NonExistent")
|
||||
}
|
||||
pub fn special_use(&self) -> Option<&'static str> {
|
||||
["\\Sent", "\\Trash", "\\Junk", "\\Drafts", "\\Archive", "\\All", "\\Flagged"]
|
||||
.into_iter()
|
||||
.find(|s| self.has_attr(s))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Namespaces {
|
||||
/// (prefix, delimiter) of the personal namespace(s).
|
||||
pub personal: Vec<(String, Option<char>)>,
|
||||
/// Prefixes of other-users and shared namespaces (to skip).
|
||||
pub foreign: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SelectInfo {
|
||||
pub exists: u32,
|
||||
pub uidvalidity: Option<u32>,
|
||||
pub permanent_flags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl SelectInfo {
|
||||
/// Whether the mailbox accepts this flag permanently.
|
||||
pub fn accepts_flag(&self, f: &str) -> bool {
|
||||
match &self.permanent_flags {
|
||||
None => true,
|
||||
Some(p) => {
|
||||
p.iter().any(|x| x.eq_ignore_ascii_case(f)) || (!f.starts_with('\\') && p.iter().any(|x| x == "\\*"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MsgMeta {
|
||||
pub uid: u32,
|
||||
pub flags: Vec<String>,
|
||||
pub internaldate: Option<String>,
|
||||
pub size: u64,
|
||||
pub headers: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Fetched {
|
||||
pub uid: u32,
|
||||
pub flags: Vec<String>,
|
||||
pub internaldate: Option<String>,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct CmdResult {
|
||||
pub untagged: Vec<Response>,
|
||||
pub status: Status,
|
||||
}
|
||||
|
||||
/// A NO/BAD completion, kept typed so callers can inspect the response code.
|
||||
#[derive(Debug)]
|
||||
pub struct ImapNo {
|
||||
pub cmd: String,
|
||||
pub status: Status,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ImapNo {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} -> {}", self.cmd, self.status.word)?;
|
||||
if let Some(c) = &self.status.code {
|
||||
write!(f, " [{c}]")?;
|
||||
}
|
||||
write!(f, " {}", self.status.text)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ImapNo {}
|
||||
|
||||
pub enum Login<'a> {
|
||||
Plain { user: &'a str, password: &'a str },
|
||||
Authzid { authzid: &'a str, authcid: &'a str, password: &'a str },
|
||||
}
|
||||
|
||||
fn tls_config(ep: &Endpoint) -> Result<Arc<rustls::ClientConfig>> {
|
||||
if let Some(ca) = &ep.ca_file {
|
||||
use rustls_pki_types::pem::PemObject;
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
for cert in rustls_pki_types::CertificateDer::pem_file_iter(ca)
|
||||
.with_context(|| format!("reading CA file {}", ca.display()))?
|
||||
{
|
||||
roots.add(cert?)?;
|
||||
}
|
||||
Ok(Arc::new(rustls::ClientConfig::builder().with_root_certificates(roots).with_no_client_auth()))
|
||||
} else {
|
||||
use rustls_platform_verifier::ConfigVerifierExt;
|
||||
Ok(Arc::new(rustls::ClientConfig::with_platform_verifier()?))
|
||||
}
|
||||
}
|
||||
|
||||
async fn tls_wrap(ep: &Endpoint, tcp: TcpStream) -> Result<Box<dyn Stream>> {
|
||||
let name = ep.tls_server_name.clone().unwrap_or_else(|| ep.host.clone());
|
||||
let server_name = rustls_pki_types::ServerName::try_from(name.clone())
|
||||
.with_context(|| format!("invalid TLS server name {name}"))?;
|
||||
let connector = tokio_rustls::TlsConnector::from(tls_config(ep)?);
|
||||
let tls = connector.connect(server_name, tcp).await.with_context(|| format!("TLS handshake with {}", ep.host))?;
|
||||
Ok(Box::new(tls))
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub async fn connect(ep: &Endpoint, label: &str) -> Result<Client> {
|
||||
let addr = (ep.host.as_str(), ep.port());
|
||||
let timeout = Duration::from_secs(ep.timeout);
|
||||
let tcp = tokio::time::timeout(Duration::from_secs(30), TcpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| anyhow!("{label}: connect to {}:{} timed out", ep.host, ep.port()))?
|
||||
.with_context(|| format!("{label}: connect to {}:{}", ep.host, ep.port()))?;
|
||||
tcp.set_nodelay(true).ok();
|
||||
let stream: Box<dyn Stream> = match ep.tls {
|
||||
TlsMode::Implicit => tls_wrap(ep, tcp).await?,
|
||||
TlsMode::None => Box::new(tcp),
|
||||
TlsMode::Starttls => {
|
||||
let mut tcp = tcp;
|
||||
starttls_preamble(&mut tcp, label, timeout).await?;
|
||||
tls_wrap(ep, tcp).await?
|
||||
}
|
||||
};
|
||||
let mut c =
|
||||
Client { io: BufStream::new(stream), tag: 0, caps: HashSet::new(), label: label.to_string(), timeout };
|
||||
if ep.tls != TlsMode::Starttls {
|
||||
c.greeting().await?;
|
||||
}
|
||||
c.capability().await?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
async fn greeting(&mut self) -> Result<()> {
|
||||
let r = self.read_response().await?;
|
||||
match r.status() {
|
||||
Some(s) if s.word == "OK" || s.word == "PREAUTH" => {
|
||||
if let Some(code) = &s.code {
|
||||
self.set_caps_from_code(code);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => bail!("{}: unexpected greeting: {}", self.label, r.summary()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_caps_from_code(&mut self, code: &str) {
|
||||
let mut it = code.split_whitespace();
|
||||
if it.next().is_some_and(|w| w.eq_ignore_ascii_case("CAPABILITY")) {
|
||||
self.caps = it.map(|c| c.to_ascii_uppercase()).collect();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_cap(&self, c: &str) -> bool {
|
||||
self.caps.contains(&c.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
async fn read_response(&mut self) -> Result<Response> {
|
||||
let fut = async {
|
||||
let mut parts = Vec::new();
|
||||
loop {
|
||||
let mut line = Vec::new();
|
||||
let n = self.io.read_until(b'\n', &mut line).await?;
|
||||
if n == 0 {
|
||||
bail!("{}: connection closed by server", self.label);
|
||||
}
|
||||
while matches!(line.last(), Some(b'\n' | b'\r')) {
|
||||
line.pop();
|
||||
}
|
||||
if let Some(len) = proto::literal_len(&line) {
|
||||
parts.push(Part::Text(line));
|
||||
let mut buf = vec![0u8; len];
|
||||
self.io.read_exact(&mut buf).await?;
|
||||
parts.push(Part::Lit(buf));
|
||||
} else {
|
||||
parts.push(Part::Text(line));
|
||||
return Ok(Response { parts });
|
||||
}
|
||||
}
|
||||
};
|
||||
let r: Result<Response> = tokio::time::timeout(self.timeout, fut)
|
||||
.await
|
||||
.map_err(|_| anyhow!("{}: timed out waiting for server", self.label))?;
|
||||
let r = r?;
|
||||
tracing::trace!("{} S: {}", self.label, r.summary());
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Send a command made of raw text and literals; collect untagged
|
||||
/// responses until the tagged completion.
|
||||
pub async fn run(&mut self, args: Vec<Arg>, log_as: &str) -> Result<CmdResult> {
|
||||
self.tag += 1;
|
||||
let tag = format!("m{}", self.tag);
|
||||
tracing::trace!("{} C: {tag} {log_as}", self.label);
|
||||
let mut untagged = Vec::new();
|
||||
self.io.write_all(tag.as_bytes()).await?;
|
||||
self.io.write_all(b" ").await?;
|
||||
for a in args {
|
||||
match a {
|
||||
Arg::Raw(s) => self.io.write_all(s.as_bytes()).await?,
|
||||
Arg::Lit(data) => {
|
||||
let nonsync = self.has_cap("LITERAL+") || (self.has_cap("LITERAL-") && data.len() <= 4096);
|
||||
if nonsync {
|
||||
self.io.write_all(format!("{{{}+}}\r\n", data.len()).as_bytes()).await?;
|
||||
} else {
|
||||
self.io.write_all(format!("{{{}}}\r\n", data.len()).as_bytes()).await?;
|
||||
self.io.flush().await?;
|
||||
loop {
|
||||
let r = self.read_response().await?;
|
||||
match r.kind() {
|
||||
Kind::Continuation => break,
|
||||
Kind::Tagged(t) if t == tag => {
|
||||
let status = r.status().unwrap_or(Status {
|
||||
word: "BAD".into(),
|
||||
code: None,
|
||||
text: r.summary(),
|
||||
});
|
||||
return Err(ImapNo { cmd: log_as.to_string(), status }.into());
|
||||
}
|
||||
_ => untagged.push(r),
|
||||
}
|
||||
}
|
||||
}
|
||||
self.io.write_all(&data).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.io.write_all(b"\r\n").await?;
|
||||
self.io.flush().await?;
|
||||
loop {
|
||||
let r = self.read_response().await?;
|
||||
match r.kind() {
|
||||
Kind::Tagged(t) if t == tag => {
|
||||
let status =
|
||||
r.status().ok_or_else(|| anyhow!("{}: malformed completion: {}", self.label, r.summary()))?;
|
||||
if status.word != "OK" {
|
||||
return Err(ImapNo { cmd: log_as.to_string(), status }.into());
|
||||
}
|
||||
return Ok(CmdResult { untagged, status });
|
||||
}
|
||||
Kind::Continuation => bail!("{}: unexpected continuation for {log_as}", self.label),
|
||||
_ => {
|
||||
if r.status().is_some_and(|s| s.word == "BYE") && !log_as.starts_with("LOGOUT") {
|
||||
tracing::debug!("{}: server said BYE: {}", self.label, r.summary());
|
||||
}
|
||||
untagged.push(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn simple(&mut self, cmd: &str) -> Result<CmdResult> {
|
||||
self.run(vec![Arg::Raw(cmd.to_string())], cmd).await
|
||||
}
|
||||
|
||||
pub async fn capability(&mut self) -> Result<()> {
|
||||
let r = self.simple("CAPABILITY").await?;
|
||||
for u in &r.untagged {
|
||||
let v = u.values()?;
|
||||
if v.get(1).is_some_and(|x| x.is_atom("CAPABILITY")) {
|
||||
self.caps = v[2..].iter().filter_map(|x| x.text()).map(|c| c.to_ascii_uppercase()).collect();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn login(&mut self, how: Login<'_>) -> Result<()> {
|
||||
let res = match how {
|
||||
Login::Plain { user, password } => {
|
||||
if self.has_cap("LOGINDISABLED") {
|
||||
bail!("{}: server has LOGINDISABLED (use TLS)", self.label);
|
||||
}
|
||||
let mut args = vec![Arg::Raw("LOGIN ".into())];
|
||||
args.push(proto::astring(user));
|
||||
args.push(Arg::Raw(" ".into()));
|
||||
args.push(proto::astring(password));
|
||||
self.run(args, &format!("LOGIN {user} ****")).await
|
||||
}
|
||||
Login::Authzid { authzid, authcid, password } => {
|
||||
let raw = format!("{authzid}\0{authcid}\0{password}");
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
|
||||
if self.has_cap("SASL-IR") {
|
||||
self.run(
|
||||
vec![Arg::Raw(format!("AUTHENTICATE PLAIN {b64}"))],
|
||||
&format!("AUTHENTICATE PLAIN {authzid}/{authcid} ****"),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
self.authenticate_continuation(&b64, authzid, authcid).await
|
||||
}
|
||||
}
|
||||
};
|
||||
let r = res.map_err(|e| anyhow!("{}: login failed: {e}", self.label))?;
|
||||
match r.status.code.as_deref() {
|
||||
Some(code) if code.to_ascii_uppercase().starts_with("CAPABILITY") => self.set_caps_from_code(code),
|
||||
_ => self.capability().await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn authenticate_continuation(&mut self, b64: &str, authzid: &str, authcid: &str) -> Result<CmdResult> {
|
||||
self.tag += 1;
|
||||
let tag = format!("m{}", self.tag);
|
||||
self.io.write_all(format!("{tag} AUTHENTICATE PLAIN\r\n").as_bytes()).await?;
|
||||
self.io.flush().await?;
|
||||
let log_as = format!("AUTHENTICATE PLAIN {authzid}/{authcid} ****");
|
||||
let r = self.read_response().await?;
|
||||
if r.kind() != Kind::Continuation {
|
||||
bail!("{}: AUTHENTICATE PLAIN refused: {}", self.label, r.summary());
|
||||
}
|
||||
self.io.write_all(format!("{b64}\r\n").as_bytes()).await?;
|
||||
self.io.flush().await?;
|
||||
let mut untagged = Vec::new();
|
||||
loop {
|
||||
let r = self.read_response().await?;
|
||||
match r.kind() {
|
||||
Kind::Tagged(t) if t == tag => {
|
||||
let status = r.status().ok_or_else(|| anyhow!("malformed completion"))?;
|
||||
if status.word != "OK" {
|
||||
return Err(ImapNo { cmd: log_as, status }.into());
|
||||
}
|
||||
return Ok(CmdResult { untagged, status });
|
||||
}
|
||||
_ => untagged.push(r),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn namespace(&mut self) -> Result<Option<Namespaces>> {
|
||||
if !self.has_cap("NAMESPACE") && !self.has_cap("IMAP4REV2") {
|
||||
return Ok(None);
|
||||
}
|
||||
let r = self.simple("NAMESPACE").await?;
|
||||
for u in &r.untagged {
|
||||
let v = u.values()?;
|
||||
if v.get(1).is_some_and(|x| x.is_atom("NAMESPACE")) && v.len() >= 5 {
|
||||
let mut ns = Namespaces::default();
|
||||
let entries = |val: &Value| -> Vec<(String, Option<char>)> {
|
||||
val.list()
|
||||
.unwrap_or(&[])
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let l = e.list()?;
|
||||
let prefix = l.first()?.text()?;
|
||||
let delim = l.get(1).and_then(|d| d.text()).and_then(|d| d.chars().next());
|
||||
Some((prefix, delim))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
ns.personal = entries(&v[2]);
|
||||
ns.foreign = entries(&v[3]).into_iter().chain(entries(&v[4])).map(|(p, _)| p).collect();
|
||||
return Ok(Some(ns));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn list(&mut self) -> Result<Vec<Mailbox>> {
|
||||
let cmd = if self.has_cap("LIST-EXTENDED") && self.has_cap("SPECIAL-USE") {
|
||||
r#"LIST "" "*" RETURN (SPECIAL-USE)"#
|
||||
} else {
|
||||
r#"LIST "" "*""#
|
||||
};
|
||||
let r = self.simple(cmd).await?;
|
||||
let mut out: Vec<Mailbox> = Vec::new();
|
||||
for u in &r.untagged {
|
||||
let v = u.values()?;
|
||||
if !v.get(1).is_some_and(|x| x.is_atom("LIST")) || v.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
let attrs: Vec<String> = v[2].list().unwrap_or(&[]).iter().filter_map(|a| a.text()).collect();
|
||||
let delim = v[3].text().and_then(|d| d.chars().next());
|
||||
let wire = v[4].text().unwrap_or_default();
|
||||
let wire = if wire.eq_ignore_ascii_case("INBOX") { "INBOX".to_string() } else { wire };
|
||||
if let Some(existing) = out.iter_mut().find(|m| m.wire == wire) {
|
||||
existing.attrs.extend(attrs);
|
||||
continue;
|
||||
}
|
||||
out.push(Mailbox { name: utf7::decode(&wire), wire, delim, attrs });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn select(&mut self, wire: &str, readonly: bool) -> Result<SelectInfo> {
|
||||
let verb = if readonly { "EXAMINE" } else { "SELECT" };
|
||||
let mut args = vec![Arg::Raw(format!("{verb} "))];
|
||||
args.push(mailbox_arg(wire));
|
||||
let r = self.run(args, &format!("{verb} {wire}")).await?;
|
||||
let mut info = SelectInfo::default();
|
||||
for u in &r.untagged {
|
||||
if let Some(s) = u.status() {
|
||||
if let Some(code) = s.code {
|
||||
let up = code.to_ascii_uppercase();
|
||||
if let Some(rest) = up.strip_prefix("UIDVALIDITY ") {
|
||||
info.uidvalidity = rest.trim().parse().ok();
|
||||
} else if up.starts_with("PERMANENTFLAGS") {
|
||||
let open = code.find('(').unwrap_or(code.len());
|
||||
let close = code.rfind(')').unwrap_or(code.len());
|
||||
if open < close {
|
||||
info.permanent_flags =
|
||||
Some(code[open + 1..close].split_whitespace().map(|s| s.to_string()).collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let v = u.values()?;
|
||||
if v.len() >= 3 && v[2].is_atom("EXISTS") {
|
||||
info.exists = v[1].number().unwrap_or(0) as u32;
|
||||
}
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Run a UID FETCH and merge the items of all FETCH responses by UID.
|
||||
async fn uid_fetch(&mut self, set: &str, items: &str) -> Result<BTreeMap<u32, Vec<(String, Value)>>> {
|
||||
let cmd = format!("UID FETCH {set} ({items})");
|
||||
let r = self.simple(&cmd).await?;
|
||||
let mut out: BTreeMap<u32, Vec<(String, Value)>> = BTreeMap::new();
|
||||
for u in r.untagged {
|
||||
if u.status().is_some() {
|
||||
continue;
|
||||
}
|
||||
let v = u.values()?;
|
||||
if v.len() < 4 || !v[2].is_atom("FETCH") {
|
||||
continue;
|
||||
}
|
||||
let Some(list) = v[3].list() else { continue };
|
||||
let mut pairs = Vec::new();
|
||||
let mut uid = None;
|
||||
for kv in list.chunks(2) {
|
||||
if let [k, val] = kv {
|
||||
let key = k.text().unwrap_or_default().to_ascii_uppercase();
|
||||
if key == "UID" {
|
||||
uid = val.number().map(|n| n as u32);
|
||||
}
|
||||
pairs.push((key, val.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(uid) = uid {
|
||||
out.entry(uid).or_default().extend(pairs);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// UID, flags, date, size and identifying headers of every message in the
|
||||
/// selected mailbox.
|
||||
pub async fn fetch_meta(&mut self) -> Result<Vec<MsgMeta>> {
|
||||
let items = "UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID DATE FROM SUBJECT)]";
|
||||
let map = self.uid_fetch("1:*", items).await?;
|
||||
Ok(map
|
||||
.into_iter()
|
||||
.map(|(uid, pairs)| {
|
||||
let mut m = MsgMeta { uid, ..Default::default() };
|
||||
for (k, v) in pairs {
|
||||
match k.as_str() {
|
||||
"FLAGS" => m.flags = flags_of(&v),
|
||||
"INTERNALDATE" => m.internaldate = v.text(),
|
||||
"RFC822.SIZE" => m.size = v.number().unwrap_or(0),
|
||||
k if k.starts_with("BODY[") => m.headers = v.bytes().unwrap_or(&[]).to_vec(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn fetch_bodies(&mut self, uids: &[u32]) -> Result<Vec<Fetched>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let set = uid_set(uids);
|
||||
let map = self.uid_fetch(&set, "UID FLAGS INTERNALDATE BODY.PEEK[]").await?;
|
||||
let mut out = Vec::new();
|
||||
for (uid, pairs) in map {
|
||||
let mut f = Fetched { uid, ..Default::default() };
|
||||
let mut got_body = false;
|
||||
for (k, v) in pairs {
|
||||
match k.as_str() {
|
||||
"FLAGS" => f.flags = flags_of(&v),
|
||||
"INTERNALDATE" => f.internaldate = v.text(),
|
||||
k if k.starts_with("BODY[") => {
|
||||
f.body = v.bytes().unwrap_or(&[]).to_vec();
|
||||
got_body = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if got_body {
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn append(&mut self, wire: &str, flags: &[String], date: Option<&str>, body: &[u8]) -> Result<()> {
|
||||
let mut head = String::from("APPEND ");
|
||||
let mut args = Vec::new();
|
||||
args.push(Arg::Raw(std::mem::take(&mut head)));
|
||||
args.push(mailbox_arg(wire));
|
||||
if !flags.is_empty() {
|
||||
args.push(Arg::Raw(format!(" ({})", flags.join(" "))));
|
||||
}
|
||||
if let Some(d) = date {
|
||||
args.push(Arg::Raw(format!(" {}", proto::quote(d))));
|
||||
}
|
||||
args.push(Arg::Raw(" ".into()));
|
||||
args.push(Arg::Lit(body.to_vec()));
|
||||
self.run(args, &format!("APPEND {wire} <{} bytes>", body.len())).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CREATE, treating "already exists" as success.
|
||||
pub async fn create(&mut self, wire: &str) -> Result<()> {
|
||||
let args = vec![Arg::Raw("CREATE ".into()), mailbox_arg(wire)];
|
||||
match self.run(args, &format!("CREATE {wire}")).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => match e.downcast_ref::<ImapNo>() {
|
||||
Some(no)
|
||||
if no.status.code.as_deref().is_some_and(|c| c.eq_ignore_ascii_case("ALREADYEXISTS"))
|
||||
|| no.status.text.to_ascii_lowercase().contains("exist") =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn subscribe(&mut self, wire: &str) -> Result<()> {
|
||||
let args = vec![Arg::Raw("SUBSCRIBE ".into()), mailbox_arg(wire)];
|
||||
self.run(args, &format!("SUBSCRIBE {wire}")).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn uid_store_flags(&mut self, uid: u32, flags: &[String]) -> Result<()> {
|
||||
self.simple(&format!("UID STORE {uid} FLAGS.SILENT ({})", flags.join(" "))).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn logout(mut self) {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), self.simple("LOGOUT")).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn mailbox_arg(wire: &str) -> Arg {
|
||||
// Always quote mailbox names; INBOX and ASCII names are safe quoted.
|
||||
if wire.bytes().all(|c| (0x20..0x7f).contains(&c)) {
|
||||
Arg::Raw(proto::quote(wire))
|
||||
} else {
|
||||
Arg::Lit(wire.as_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
fn flags_of(v: &Value) -> Vec<String> {
|
||||
v.list().unwrap_or(&[]).iter().filter_map(|f| f.text()).collect()
|
||||
}
|
||||
|
||||
/// Compress sorted UIDs into an IMAP sequence set ("1:3,7,9:10").
|
||||
pub fn uid_set(uids: &[u32]) -> String {
|
||||
let mut sorted = uids.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < sorted.len() {
|
||||
let start = sorted[i];
|
||||
let mut end = start;
|
||||
while i + 1 < sorted.len() && sorted[i + 1] == end + 1 {
|
||||
i += 1;
|
||||
end = sorted[i];
|
||||
}
|
||||
if !out.is_empty() {
|
||||
out.push(',');
|
||||
}
|
||||
if start == end {
|
||||
out.push_str(&start.to_string());
|
||||
} else {
|
||||
out.push_str(&format!("{start}:{end}"));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Plain-text greeting + STARTTLS on the raw socket, before the TLS wrap.
|
||||
async fn starttls_preamble(tcp: &mut TcpStream, label: &str, timeout: Duration) -> Result<()> {
|
||||
let fut = async {
|
||||
let mut r = tokio::io::BufReader::new(&mut *tcp);
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).await?;
|
||||
if !line.to_ascii_uppercase().starts_with("* OK") {
|
||||
bail!("{label}: unexpected greeting: {}", line.trim_end());
|
||||
}
|
||||
r.write_all(b"s0 STARTTLS\r\n").await?;
|
||||
r.flush().await?;
|
||||
loop {
|
||||
line.clear();
|
||||
if r.read_line(&mut line).await? == 0 {
|
||||
bail!("{label}: connection closed during STARTTLS");
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("s0 ") {
|
||||
if !rest.to_ascii_uppercase().starts_with("OK") {
|
||||
bail!("{label}: STARTTLS refused: {}", line.trim_end());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !r.buffer().is_empty() {
|
||||
bail!("{label}: server sent data after STARTTLS (possible injection)");
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
tokio::time::timeout(timeout, fut).await.map_err(|_| anyhow!("{label}: STARTTLS timed out"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn uid_sets() {
|
||||
assert_eq!(uid_set(&[1, 2, 3, 7, 9, 10]), "1:3,7,9:10");
|
||||
assert_eq!(uid_set(&[5]), "5");
|
||||
assert_eq!(uid_set(&[10, 1, 2]), "1:2,10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanent_flags() {
|
||||
let s = SelectInfo { permanent_flags: Some(vec!["\\Seen".into(), "\\*".into()]), ..Default::default() };
|
||||
assert!(s.accepts_flag("\\Seen"));
|
||||
assert!(s.accepts_flag("$Forwarded"));
|
||||
assert!(!s.accepts_flag("\\Flagged"));
|
||||
let s = SelectInfo { permanent_flags: Some(vec!["\\Seen".into()]), ..Default::default() };
|
||||
assert!(!s.accepts_flag("$Forwarded"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod client;
|
||||
pub mod proto;
|
||||
pub mod utf7;
|
||||
|
||||
pub use client::{Client, Login, Mailbox, MsgMeta, Namespaces, SelectInfo};
|
||||
@@ -0,0 +1,388 @@
|
||||
//! IMAP response framing and a small tokenizer for data responses.
|
||||
//!
|
||||
//! A server response is one line, optionally interrupted by literals
|
||||
//! (`{N}\r\n` followed by N raw bytes, then the rest of the line). We keep the
|
||||
//! pieces as [`Part`]s so literal payloads (message bodies) are never scanned
|
||||
//! as text.
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Part {
|
||||
Text(Vec<u8>),
|
||||
Lit(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
Nil,
|
||||
Atom(String),
|
||||
Str(Vec<u8>),
|
||||
List(Vec<Value>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn text(&self) -> Option<String> {
|
||||
match self {
|
||||
Value::Atom(a) => Some(a.clone()),
|
||||
Value::Str(s) => Some(String::from_utf8_lossy(s).into_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn bytes(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
Value::Str(s) => Some(s),
|
||||
Value::Atom(a) => Some(a.as_bytes()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn list(&self) -> Option<&[Value]> {
|
||||
match self {
|
||||
Value::List(l) => Some(l),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn number(&self) -> Option<u64> {
|
||||
match self {
|
||||
Value::Atom(a) => a.parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn is_atom(&self, s: &str) -> bool {
|
||||
matches!(self, Value::Atom(a) if a.eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Response {
|
||||
pub parts: Vec<Part>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Kind {
|
||||
Untagged,
|
||||
Continuation,
|
||||
Tagged(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Status {
|
||||
/// OK / NO / BAD / BYE / PREAUTH (uppercased)
|
||||
pub word: String,
|
||||
/// Response code inside `[...]`, without the brackets.
|
||||
pub code: Option<String>,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
fn first(&self) -> &[u8] {
|
||||
match self.parts.first() {
|
||||
Some(Part::Text(t)) => t,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> Kind {
|
||||
let f = self.first();
|
||||
if f.starts_with(b"* ") || f == b"*" {
|
||||
Kind::Untagged
|
||||
} else if f.starts_with(b"+") {
|
||||
Kind::Continuation
|
||||
} else {
|
||||
let end = f.iter().position(|&c| c == b' ').unwrap_or(f.len());
|
||||
Kind::Tagged(String::from_utf8_lossy(&f[..end]).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Short printable form for logs (literals elided).
|
||||
pub fn summary(&self) -> String {
|
||||
let mut s = String::new();
|
||||
for p in &self.parts {
|
||||
match p {
|
||||
Part::Text(t) => s.push_str(&String::from_utf8_lossy(t)),
|
||||
Part::Lit(l) => s.push_str(&format!("<{} bytes>", l.len())),
|
||||
}
|
||||
if s.len() > 300 {
|
||||
s.truncate(300);
|
||||
s.push('…');
|
||||
break;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// For untagged/tagged status responses (`* OK [..] text`, `a1 NO text`).
|
||||
pub fn status(&self) -> Option<Status> {
|
||||
let f = self.first();
|
||||
let sp = f.iter().position(|&c| c == b' ')?;
|
||||
let rest = &f[sp + 1..];
|
||||
let wend = rest.iter().position(|&c| c == b' ').unwrap_or(rest.len());
|
||||
let word = String::from_utf8_lossy(&rest[..wend]).to_ascii_uppercase();
|
||||
if !matches!(word.as_str(), "OK" | "NO" | "BAD" | "BYE" | "PREAUTH") {
|
||||
return None;
|
||||
}
|
||||
let mut tail = if wend < rest.len() { &rest[wend + 1..] } else { &[][..] };
|
||||
let mut code = None;
|
||||
if tail.first() == Some(&b'[') {
|
||||
let mut depth = 0;
|
||||
for (i, &c) in tail.iter().enumerate() {
|
||||
match c {
|
||||
b'[' => depth += 1,
|
||||
b']' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
code = Some(String::from_utf8_lossy(&tail[1..i]).into_owned());
|
||||
tail = &tail[(i + 1).min(tail.len())..];
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Status { word, code, text: String::from_utf8_lossy(tail).trim().to_string() })
|
||||
}
|
||||
|
||||
/// Tokenize the whole response (including the leading `*`).
|
||||
pub fn values(&self) -> Result<Vec<Value>> {
|
||||
let mut c = Cursor { parts: &self.parts, pi: 0, bi: 0 };
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
c.skip_spaces();
|
||||
if c.at_end() {
|
||||
break;
|
||||
}
|
||||
out.push(c.value()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
struct Cursor<'a> {
|
||||
parts: &'a [Part],
|
||||
pi: usize,
|
||||
bi: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn text(&self) -> Option<&'a [u8]> {
|
||||
match self.parts.get(self.pi) {
|
||||
Some(Part::Text(t)) => Some(t.as_slice()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.text().and_then(|t| t.get(self.bi).copied())
|
||||
}
|
||||
fn at_end(&self) -> bool {
|
||||
match self.text() {
|
||||
Some(t) => self.bi >= t.len() && self.pi + 1 >= self.parts.len(),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
fn skip_spaces(&mut self) {
|
||||
while self.peek() == Some(b' ') {
|
||||
self.bi += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn value(&mut self) -> Result<Value> {
|
||||
self.skip_spaces();
|
||||
match self.peek() {
|
||||
None => bail!("unexpected end of response"),
|
||||
Some(b'(') => {
|
||||
self.bi += 1;
|
||||
let mut items = Vec::new();
|
||||
loop {
|
||||
self.skip_spaces();
|
||||
match self.peek() {
|
||||
Some(b')') => {
|
||||
self.bi += 1;
|
||||
return Ok(Value::List(items));
|
||||
}
|
||||
None => bail!("unterminated list"),
|
||||
_ => items.push(self.value()?),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(b'"') => {
|
||||
self.bi += 1;
|
||||
let t = self.text().unwrap();
|
||||
let mut s = Vec::new();
|
||||
loop {
|
||||
let c = *t.get(self.bi).ok_or_else(|| anyhow!("unterminated quoted string"))?;
|
||||
self.bi += 1;
|
||||
match c {
|
||||
b'\\' => {
|
||||
let n = *t.get(self.bi).ok_or_else(|| anyhow!("bad escape"))?;
|
||||
self.bi += 1;
|
||||
s.push(n);
|
||||
}
|
||||
b'"' => return Ok(Value::Str(s)),
|
||||
_ => s.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(b'{') => {
|
||||
let t = self.text().unwrap();
|
||||
let close =
|
||||
t[self.bi..].iter().position(|&c| c == b'}').ok_or_else(|| anyhow!("bad literal marker"))?;
|
||||
if self.bi + close + 1 != t.len() {
|
||||
bail!("literal marker not at end of line");
|
||||
}
|
||||
let lit = match self.parts.get(self.pi + 1) {
|
||||
Some(Part::Lit(l)) => l.clone(),
|
||||
_ => bail!("literal payload missing"),
|
||||
};
|
||||
self.pi += 2;
|
||||
self.bi = 0;
|
||||
Ok(Value::Str(lit))
|
||||
}
|
||||
Some(_) => {
|
||||
let t = self.text().unwrap();
|
||||
let start = self.bi;
|
||||
let mut depth = 0usize;
|
||||
while let Some(&c) = t.get(self.bi) {
|
||||
match c {
|
||||
b'[' => depth += 1,
|
||||
b']' => depth = depth.saturating_sub(1),
|
||||
b' ' | b'(' | b')' if depth == 0 => break,
|
||||
_ => {}
|
||||
}
|
||||
self.bi += 1;
|
||||
}
|
||||
let a = String::from_utf8_lossy(&t[start..self.bi]).into_owned();
|
||||
if a.eq_ignore_ascii_case("NIL") { Ok(Value::Nil) } else { Ok(Value::Atom(a)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If a line (CRLF already stripped) ends with a literal marker `{N}` or
|
||||
/// `{N+}`, return N.
|
||||
pub fn literal_len(line: &[u8]) -> Option<usize> {
|
||||
if line.last() != Some(&b'}') {
|
||||
return None;
|
||||
}
|
||||
let open = line.iter().rposition(|&c| c == b'{')?;
|
||||
let inner = &line[open + 1..line.len() - 1];
|
||||
let digits = inner.strip_suffix(b"+").unwrap_or(inner);
|
||||
if digits.is_empty() || !digits.iter().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(digits).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Encode a string as an IMAP astring argument (atom, quoted or literal).
|
||||
pub enum Arg {
|
||||
Raw(String),
|
||||
Lit(Vec<u8>),
|
||||
}
|
||||
|
||||
pub fn astring(s: &str) -> Arg {
|
||||
let atom_ok = !s.is_empty() && s.bytes().all(|c| c > 0x20 && c < 0x7f && !b"(){%*\"\\]".contains(&c));
|
||||
if atom_ok {
|
||||
Arg::Raw(s.to_string())
|
||||
} else if s.bytes().all(|c| (0x20..0x7f).contains(&c)) {
|
||||
Arg::Raw(quote(s))
|
||||
} else {
|
||||
Arg::Lit(s.as_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quote(s: &str) -> String {
|
||||
let mut q = String::with_capacity(s.len() + 2);
|
||||
q.push('"');
|
||||
for c in s.chars() {
|
||||
if c == '"' || c == '\\' {
|
||||
q.push('\\');
|
||||
}
|
||||
q.push(c);
|
||||
}
|
||||
q.push('"');
|
||||
q
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn resp(parts: Vec<Part>) -> Response {
|
||||
Response { parts }
|
||||
}
|
||||
fn t(s: &str) -> Part {
|
||||
Part::Text(s.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_response() {
|
||||
let r = resp(vec![t(r#"* LIST (\HasNoChildren \Sent) "/" "Sent Items""#)]);
|
||||
let v = r.values().unwrap();
|
||||
assert_eq!(v[1], Value::Atom("LIST".into()));
|
||||
assert_eq!(v[2], Value::List(vec![Value::Atom("\\HasNoChildren".into()), Value::Atom("\\Sent".into())]));
|
||||
assert_eq!(v[3], Value::Str(b"/".to_vec()));
|
||||
assert_eq!(v[4], Value::Str(b"Sent Items".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_with_literal_and_section() {
|
||||
let r = resp(vec![
|
||||
t(
|
||||
r#"* 3 FETCH (UID 17 FLAGS (\Seen $Forwarded) INTERNALDATE "17-Jul-1996 02:44:25 -0700" RFC822.SIZE 4286 BODY[HEADER.FIELDS (MESSAGE-ID)] {26}"#,
|
||||
),
|
||||
// payload contains parens and quotes that must not be tokenized
|
||||
Part::Lit(b"Message-ID: <a@b>\r\n\r\n(x)\")".to_vec()),
|
||||
t(")"),
|
||||
]);
|
||||
let v = r.values().unwrap();
|
||||
let items = v[3].list().unwrap();
|
||||
assert!(items[0].is_atom("UID"));
|
||||
assert_eq!(items[1].number(), Some(17));
|
||||
assert_eq!(items[6], Value::Atom("RFC822.SIZE".into()));
|
||||
assert_eq!(items[7].number(), Some(4286));
|
||||
assert_eq!(items[8], Value::Atom("BODY[HEADER.FIELDS (MESSAGE-ID)]".into()));
|
||||
assert_eq!(items[9].bytes().unwrap().len(), 26);
|
||||
assert_eq!(items.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_codes() {
|
||||
let r = resp(vec![t("a5 OK [APPENDUID 38505 3955] APPEND completed")]);
|
||||
assert_eq!(r.kind(), Kind::Tagged("a5".into()));
|
||||
let s = r.status().unwrap();
|
||||
assert_eq!(s.word, "OK");
|
||||
assert_eq!(s.code.as_deref(), Some("APPENDUID 38505 3955"));
|
||||
assert_eq!(s.text, "APPEND completed");
|
||||
let r = resp(vec![t("* OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited")]);
|
||||
assert_eq!(r.kind(), Kind::Untagged);
|
||||
assert_eq!(r.status().unwrap().code.as_deref(), Some("PERMANENTFLAGS (\\Deleted \\Seen \\*)"));
|
||||
// free text with stray quote must not break status parsing
|
||||
let r = resp(vec![t("* OK Stalwart \"IMAP ready")]);
|
||||
assert_eq!(r.status().unwrap().text, "Stalwart \"IMAP ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_marker() {
|
||||
assert_eq!(literal_len(b"* 1 FETCH (BODY[] {123}"), Some(123));
|
||||
assert_eq!(literal_len(b"foo {12+}"), Some(12));
|
||||
assert_eq!(literal_len(b"* OK {bar}"), None);
|
||||
assert_eq!(literal_len(b"* OK"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn astring_encoding() {
|
||||
assert!(matches!(astring("INBOX"), Arg::Raw(s) if s == "INBOX"));
|
||||
assert!(matches!(astring("Sent Items"), Arg::Raw(s) if s == "\"Sent Items\""));
|
||||
assert!(matches!(astring("a\"b"), Arg::Raw(s) if s == "\"a\\\"b\""));
|
||||
assert!(matches!(astring("pässword"), Arg::Lit(_)));
|
||||
assert!(matches!(astring(""), Arg::Raw(s) if s == "\"\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_nil() {
|
||||
let r = resp(vec![t(r#"* NAMESPACE (("" "/")) NIL (("Shared Folders/" "/"))"#)]);
|
||||
let v = r.values().unwrap();
|
||||
assert_eq!(v[2].list().unwrap()[0].list().unwrap()[0], Value::Str(vec![]));
|
||||
assert_eq!(v[3], Value::Nil);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! IMAP modified UTF-7 (RFC 3501 §5.1.3) for mailbox names on the wire.
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,";
|
||||
|
||||
fn b64_val(c: u8) -> Option<u32> {
|
||||
B64.iter().position(|&x| x == c).map(|p| p as u32)
|
||||
}
|
||||
|
||||
/// Encode a Unicode mailbox name into modified UTF-7.
|
||||
pub fn encode(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut pending: Vec<u16> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u16>, out: &mut String| {
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bytes: Vec<u8> = pending.iter().flat_map(|u| u.to_be_bytes()).collect();
|
||||
out.push('&');
|
||||
let mut bits: u32 = 0;
|
||||
let mut nbits = 0;
|
||||
for b in bytes {
|
||||
bits = (bits << 8) | b as u32;
|
||||
nbits += 8;
|
||||
while nbits >= 6 {
|
||||
nbits -= 6;
|
||||
out.push(B64[((bits >> nbits) & 0x3f) as usize] as char);
|
||||
}
|
||||
}
|
||||
if nbits > 0 {
|
||||
out.push(B64[((bits << (6 - nbits)) & 0x3f) as usize] as char);
|
||||
}
|
||||
out.push('-');
|
||||
pending.clear();
|
||||
};
|
||||
for ch in s.chars() {
|
||||
if ('\u{20}'..='\u{7e}').contains(&ch) {
|
||||
flush(&mut pending, &mut out);
|
||||
if ch == '&' {
|
||||
out.push_str("&-");
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
} else {
|
||||
let mut buf = [0u16; 2];
|
||||
pending.extend_from_slice(ch.encode_utf16(&mut buf));
|
||||
}
|
||||
}
|
||||
flush(&mut pending, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode a modified UTF-7 mailbox name. Invalid input is returned unchanged
|
||||
/// (some servers send raw UTF-8 names; we pass those through).
|
||||
pub fn decode(s: &str) -> String {
|
||||
try_decode(s).unwrap_or_else(|| s.to_string())
|
||||
}
|
||||
|
||||
fn try_decode(s: &str) -> Option<String> {
|
||||
let b = s.as_bytes();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'&' {
|
||||
let end = i + 1 + b[i + 1..].iter().position(|&c| c == b'-')?;
|
||||
if end == i + 1 {
|
||||
out.push('&');
|
||||
} else {
|
||||
let mut bits: u32 = 0;
|
||||
let mut nbits = 0;
|
||||
let mut units = Vec::new();
|
||||
let mut bytes = Vec::new();
|
||||
for &c in &b[i + 1..end] {
|
||||
bits = (bits << 6) | b64_val(c)?;
|
||||
nbits += 6;
|
||||
if nbits >= 8 {
|
||||
nbits -= 8;
|
||||
bytes.push(((bits >> nbits) & 0xff) as u8);
|
||||
}
|
||||
}
|
||||
if bytes.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
for pair in bytes.chunks(2) {
|
||||
units.push(u16::from_be_bytes([pair[0], pair[1]]));
|
||||
}
|
||||
out.push_str(&String::from_utf16(&units).ok()?);
|
||||
}
|
||||
i = end + 1;
|
||||
} else {
|
||||
// Raw non-ASCII means the server is not speaking mUTF-7.
|
||||
if b[i] >= 0x80 {
|
||||
return None;
|
||||
}
|
||||
out.push(b[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
for s in ["INBOX", "Sent Items", "Café", "日本語/テスト", "A&B", "~peter/mail/台北/日本語", "😀 emoji"]
|
||||
{
|
||||
assert_eq!(decode(&encode(s)), s, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc_examples() {
|
||||
assert_eq!(encode("~peter/mail/台北/日本語"), "~peter/mail/&U,BTFw-/&ZeVnLIqe-");
|
||||
assert_eq!(encode("A&B"), "A&-B");
|
||||
assert_eq!(decode("&ZeVnLIqe-"), "日本語");
|
||||
}
|
||||
}
|
||||
+544
@@ -0,0 +1,544 @@
|
||||
//! mailmig: move mailboxes from Stalwart to Simple NixOS Mailserver.
|
||||
//!
|
||||
//! Order of operations (see README):
|
||||
//! 1. `inventory` read accounts/aliases/lists from Stalwart -> plan.toml + secrets.json
|
||||
//! 2. `users` render SNM accounts (Nix), password hashes, master-user module
|
||||
//! 3. `check` log in to both sides for every account
|
||||
//! 4. `sync` copy mail (idempotent; repeat for delta passes)
|
||||
//! 5. `dns` plan / apply / verify the mail DNS records
|
||||
//! 6. `verify` final comparison, then remove the master user
|
||||
|
||||
mod config;
|
||||
mod dns;
|
||||
mod imap;
|
||||
mod plan;
|
||||
mod snm;
|
||||
mod stalwart;
|
||||
mod sync;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use config::Config;
|
||||
use plan::{Plan, Secrets};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "Migrate mail from Stalwart to Simple NixOS Mailserver over IMAP")]
|
||||
struct Cli {
|
||||
/// Config file.
|
||||
#[arg(short, long, global = true, default_value = "mailmig.toml")]
|
||||
config: PathBuf,
|
||||
/// Plan file (accounts to migrate).
|
||||
#[arg(long, global = true, default_value = "plan.toml")]
|
||||
plan: PathBuf,
|
||||
/// Secrets file (password hashes from the source; keep private).
|
||||
#[arg(long, global = true, default_value = "secrets.json")]
|
||||
secrets: PathBuf,
|
||||
/// More output (-v debug, -vv IMAP protocol trace without passwords).
|
||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||
verbose: u8,
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Write an example mailmig.toml.
|
||||
Init {
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Step 1: read the account directory from Stalwart into plan.toml + secrets.json.
|
||||
Inventory {
|
||||
/// Overwrite an existing plan.toml (it is meant to be hand-edited).
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Also save the raw API response (contains hashes!).
|
||||
#[arg(long)]
|
||||
raw: Option<PathBuf>,
|
||||
/// Build the plan from a saved raw response instead of the API.
|
||||
#[arg(long)]
|
||||
from_raw: Option<PathBuf>,
|
||||
},
|
||||
/// Step 2: render SNM accounts (Nix), password hashes and the master-user module.
|
||||
Users {
|
||||
#[arg(long, default_value = "out")]
|
||||
out: PathBuf,
|
||||
/// Generate random passwords for accounts whose hash cannot be carried over.
|
||||
#[arg(long)]
|
||||
generate_missing: bool,
|
||||
},
|
||||
/// Step 3: log in to source and destination for every account (no changes).
|
||||
Check {
|
||||
#[arg(short, long)]
|
||||
account: Vec<String>,
|
||||
},
|
||||
/// Step 4: copy mail. Safe to repeat: only missing messages are copied.
|
||||
Sync {
|
||||
#[arg(short, long)]
|
||||
account: Vec<String>,
|
||||
/// Accounts in parallel (overrides [sync] jobs).
|
||||
#[arg(short, long)]
|
||||
jobs: Option<usize>,
|
||||
/// Show what would be copied without changing anything.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
/// Also copy flags (read/answered/flagged) of already-copied messages
|
||||
/// from the source. Use before the DNS switch only: afterwards users
|
||||
/// work on the new server and this would undo their changes.
|
||||
#[arg(long)]
|
||||
sync_flags: bool,
|
||||
/// JSON report path (default reports/sync-<time>.json).
|
||||
#[arg(long)]
|
||||
report: Option<PathBuf>,
|
||||
},
|
||||
/// Compare source and destination; exit 1 if anything is missing.
|
||||
Verify {
|
||||
#[arg(short, long)]
|
||||
account: Vec<String>,
|
||||
#[arg(long)]
|
||||
report: Option<PathBuf>,
|
||||
},
|
||||
/// Step 5: mail DNS records.
|
||||
Dns {
|
||||
#[command(subcommand)]
|
||||
cmd: DnsCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DnsCmd {
|
||||
/// Show the changes per domain (reads the provider, changes nothing).
|
||||
Plan {
|
||||
#[arg(short, long)]
|
||||
domain: Vec<String>,
|
||||
},
|
||||
/// Apply the changes (writes a rollback journal first).
|
||||
Apply {
|
||||
#[arg(short, long)]
|
||||
domain: Vec<String>,
|
||||
/// Required: confirm that the plan was reviewed.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Undo an apply using its journal file.
|
||||
Rollback {
|
||||
journal: PathBuf,
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Print the records as zone-file lines (for zones served by Knot/ns1).
|
||||
Zone {
|
||||
#[arg(short, long)]
|
||||
domain: Vec<String>,
|
||||
},
|
||||
/// Check the records via public DNS (DNS-over-HTTPS).
|
||||
Verify {
|
||||
#[arg(short, long)]
|
||||
domain: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn init_crypto() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
}
|
||||
|
||||
pub fn http_client() -> Result<reqwest::Client> {
|
||||
init_crypto();
|
||||
reqwest::Client::builder()
|
||||
.user_agent(concat!("mailmig/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.context("building HTTP client")
|
||||
}
|
||||
|
||||
fn now_stamp() -> String {
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
let filter = match cli.verbose {
|
||||
0 => "mailmig=info",
|
||||
1 => "mailmig=debug",
|
||||
_ => "mailmig=trace",
|
||||
};
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()))
|
||||
.with_target(false)
|
||||
.init();
|
||||
init_crypto();
|
||||
match run(cli).await {
|
||||
Ok(code) => std::process::exit(code),
|
||||
Err(e) => {
|
||||
eprintln!("error: {e:#}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(cli: Cli) -> Result<i32> {
|
||||
if let Cmd::Init { force } = &cli.cmd {
|
||||
if cli.config.exists() && !force {
|
||||
bail!("{} exists (use --force)", cli.config.display());
|
||||
}
|
||||
std::fs::write(&cli.config, config::EXAMPLE)?;
|
||||
println!("wrote {}; edit it, then run `mailmig inventory`", cli.config.display());
|
||||
return Ok(0);
|
||||
}
|
||||
let cfg = Config::load(&cli.config)?;
|
||||
match cli.cmd {
|
||||
Cmd::Init { .. } => unreachable!(),
|
||||
Cmd::Inventory { force, raw, from_raw } => inventory(&cfg, &cli.plan, &cli.secrets, force, raw, from_raw).await,
|
||||
Cmd::Users { out, generate_missing } => {
|
||||
let plan = Plan::load(&cli.plan)?;
|
||||
let secrets = Secrets::load(&cli.secrets)?;
|
||||
let r = snm::write_users(&plan, &secrets, &cfg.snm, &out, generate_missing)?;
|
||||
let o = out.display();
|
||||
println!(
|
||||
"wrote {o}/mail-accounts.nix ({} accounts, {} forwards, {} domains)",
|
||||
plan.accounts.len(),
|
||||
plan.forwards.len(),
|
||||
plan.domains.len()
|
||||
);
|
||||
println!("wrote {} password hashes to {o}/hashes/ and {o}/clan-vars-set.sh", r.hashes_written);
|
||||
println!("wrote {o}/dovecot-master-user.nix (temporary master user for the copy)");
|
||||
if !r.generated.is_empty() {
|
||||
println!("generated new passwords for {} accounts: {o}/generated-passwords.txt", r.generated.len());
|
||||
}
|
||||
if !r.missing.is_empty() {
|
||||
println!("\n{} accounts need a NEW password (no usable hash on the source):", r.missing.len());
|
||||
for m in &r.missing {
|
||||
println!(" {m}");
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
Cmd::Check { account } => check(&cfg, &cli.plan, &cli.secrets, &account).await,
|
||||
Cmd::Sync { account, jobs, dry_run, sync_flags, report } => {
|
||||
let mut cfg = cfg;
|
||||
if let Some(j) = jobs {
|
||||
cfg.sync.jobs = j;
|
||||
}
|
||||
let opts = sync::SyncOpts { dry_run, sync_flags };
|
||||
sync_cmd(cfg, &cli.plan, &cli.secrets, &account, opts, report, false).await
|
||||
}
|
||||
Cmd::Verify { account, report } => {
|
||||
let opts = sync::SyncOpts { dry_run: true, sync_flags: false };
|
||||
sync_cmd(cfg, &cli.plan, &cli.secrets, &account, opts, report, true).await
|
||||
}
|
||||
Cmd::Dns { cmd } => dns_cmd(&cfg, &cli.plan, cmd).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn inventory(
|
||||
cfg: &Config,
|
||||
plan_path: &Path,
|
||||
secrets_path: &Path,
|
||||
force: bool,
|
||||
raw: Option<PathBuf>,
|
||||
from_raw: Option<PathBuf>,
|
||||
) -> Result<i32> {
|
||||
if plan_path.exists() && !force {
|
||||
bail!("{} exists and may contain your edits (use --force to regenerate)", plan_path.display());
|
||||
}
|
||||
let (dir, raw_json) = if let Some(f) = from_raw {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&f)?).with_context(|| format!("parsing {}", f.display()))?;
|
||||
let mut dir = if let Some(arr) = v.as_array() {
|
||||
stalwart::rest_to_directory(arr)
|
||||
} else {
|
||||
let get = |k: &str| v.get(k).and_then(|x| x.as_array()).cloned().unwrap_or_default();
|
||||
stalwart::jmap_to_directory(&get("Domain"), &get("Account"), &get("MailingList"))
|
||||
};
|
||||
dir.source = format!("file {}", f.display());
|
||||
(dir, v)
|
||||
} else {
|
||||
let api_cfg = cfg.stalwart.as_ref().ok_or_else(|| anyhow!("[stalwart] section missing in config"))?;
|
||||
let api = stalwart::Api::new(api_cfg)?;
|
||||
api.directory(api_cfg.kind).await?
|
||||
};
|
||||
if let Some(r) = raw {
|
||||
plan::write_private(&r, serde_json::to_string_pretty(&raw_json)?.as_bytes())?;
|
||||
println!("wrote raw API response to {} (contains password hashes)", r.display());
|
||||
}
|
||||
let generated = format!("{} at {}", dir.source, chrono::Utc::now().to_rfc3339());
|
||||
let (plan, secrets) = plan::build_plan(&dir, generated);
|
||||
plan.save(plan_path)?;
|
||||
secrets.save(secrets_path)?;
|
||||
let count = |p: &str| plan.accounts.iter().filter(|a| a.password == p).count();
|
||||
println!(
|
||||
"{} accounts ({} carried passwords, {} plaintext, {} need new), {} forwards, {} domains: {}",
|
||||
plan.accounts.len(),
|
||||
count("carried"),
|
||||
count("plaintext"),
|
||||
count("new"),
|
||||
plan.forwards.len(),
|
||||
plan.domains.len(),
|
||||
plan.domains.join(", ")
|
||||
);
|
||||
println!("wrote {} and {}", plan_path.display(), secrets_path.display());
|
||||
if !plan.notes.is_empty() {
|
||||
println!("\nreview these notes (also in the plan):");
|
||||
for n in &plan.notes {
|
||||
println!(" - {n}");
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn select_accounts(plan: &Plan, filter: &[String], only_migrating: bool) -> Result<Vec<plan::Account>> {
|
||||
let mut out = Vec::new();
|
||||
if filter.is_empty() {
|
||||
out.extend(plan.accounts.iter().filter(|a| !only_migrating || a.migrate_mail).cloned());
|
||||
} else {
|
||||
for f in filter {
|
||||
let a = plan.account(f).ok_or_else(|| anyhow!("account {f} is not in the plan"))?;
|
||||
out.push(a.clone());
|
||||
}
|
||||
}
|
||||
sync::ensure_accounts(out)
|
||||
}
|
||||
|
||||
async fn check(cfg: &Config, plan_path: &Path, secrets_path: &Path, filter: &[String]) -> Result<i32> {
|
||||
let plan = Plan::load(plan_path)?;
|
||||
let secrets = Secrets::load(secrets_path)?;
|
||||
let src = sync::Side::new("source", &cfg.source, &secrets)?;
|
||||
let dst = sync::Side::new("destination", &cfg.destination, &secrets)?;
|
||||
let mut failed = 0;
|
||||
for a in select_accounts(&plan, filter, true)? {
|
||||
match sync::check_account(&src, &dst, &a).await {
|
||||
Ok((ns, nd)) => println!("OK {:<40} source {ns} folders, destination {nd} folders", a.login),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
println!("FAIL {:<40} {e:#}", a.login);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(if failed > 0 { 1 } else { 0 })
|
||||
}
|
||||
|
||||
async fn sync_cmd(
|
||||
cfg: Config,
|
||||
plan_path: &Path,
|
||||
secrets_path: &Path,
|
||||
filter: &[String],
|
||||
opts: sync::SyncOpts,
|
||||
report: Option<PathBuf>,
|
||||
verify: bool,
|
||||
) -> Result<i32> {
|
||||
let plan = Plan::load(plan_path)?;
|
||||
let secrets = Secrets::load(secrets_path)?;
|
||||
let accounts = select_accounts(&plan, filter, true)?;
|
||||
let src = Arc::new(sync::Side::new("source", &cfg.source, &secrets)?);
|
||||
let dst = Arc::new(sync::Side::new("destination", &cfg.destination, &secrets)?);
|
||||
let started = std::time::Instant::now();
|
||||
let reports = sync::run_sync(Arc::new(cfg), src, dst, accounts, opts).await;
|
||||
|
||||
let verb = if opts.dry_run { "missing" } else { "copied" };
|
||||
println!();
|
||||
println!(
|
||||
"{:<36} {:>7} {:>9} {:>9} {:>9} {:>7} {:>7} status",
|
||||
"account", "folders", "source", "already", verb, "failed", "flags"
|
||||
);
|
||||
let mut bad = 0;
|
||||
let mut missing_total = 0;
|
||||
for r in &reports {
|
||||
let missing = r.total(|f| f.missing);
|
||||
missing_total += missing;
|
||||
let status = match (&r.error, r.ok) {
|
||||
(Some(e), _) => format!("ERROR: {}", e.lines().next().unwrap_or("")),
|
||||
(None, false) => "partial (see report)".into(),
|
||||
(None, true) if verify && missing > 0 => "MISSING".into(),
|
||||
_ => "ok".into(),
|
||||
};
|
||||
if !r.ok || r.error.is_some() || (verify && missing > 0) {
|
||||
bad += 1;
|
||||
}
|
||||
println!(
|
||||
"{:<36} {:>7} {:>9} {:>9} {:>9} {:>7} {:>7} {status}",
|
||||
r.login,
|
||||
r.folders.len(),
|
||||
r.total(|f| f.src_messages),
|
||||
r.total(|f| f.dst_messages_before),
|
||||
if opts.dry_run { missing } else { r.total(|f| f.copied) },
|
||||
r.total(|f| f.failed),
|
||||
r.total(|f| f.flags_updated),
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"\n{} accounts in {:.0?}{}",
|
||||
reports.len(),
|
||||
started.elapsed(),
|
||||
if opts.dry_run { format!(", {missing_total} messages missing on the destination") } else { String::new() }
|
||||
);
|
||||
let kind = if verify { "verify" } else { "sync" };
|
||||
let path = report.unwrap_or_else(|| PathBuf::from(format!("reports/{kind}-{}.json", now_stamp())));
|
||||
if let Some(dir) = path.parent()
|
||||
&& !dir.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&reports)?)?;
|
||||
println!("report: {}", path.display());
|
||||
Ok(if bad > 0 { 1 } else { 0 })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS
|
||||
|
||||
fn dns_domains(cfg: &config::DnsCfg, plan_path: &Path, filter: &[String]) -> Result<Vec<String>> {
|
||||
if !filter.is_empty() {
|
||||
return Ok(filter.iter().map(|d| dns::norm_host(d)).collect());
|
||||
}
|
||||
if !cfg.domains.is_empty() {
|
||||
return Ok(cfg.domains.iter().map(|d| dns::norm_host(d)).collect());
|
||||
}
|
||||
Ok(Plan::load(plan_path)?.domains)
|
||||
}
|
||||
|
||||
fn desired_for(cfg: &config::DnsCfg, d: &str) -> Result<Vec<dns::Rec>> {
|
||||
let skip = cfg.overrides.get(d).is_some_and(|o| o.skip_dkim);
|
||||
let dkim = if skip { None } else { Some(dns::load_dkim(&cfg.dkim_dir, d, &cfg.dkim_selector)?) };
|
||||
dns::desired_records(cfg, d, dkim.as_deref())
|
||||
}
|
||||
|
||||
fn cloudflare(cfg: &config::DnsCfg) -> Result<dns::cloudflare::Cloudflare> {
|
||||
if cfg.provider != "cloudflare" {
|
||||
bail!("dns.provider = '{}' has no API; use `mailmig dns zone`", cfg.provider);
|
||||
}
|
||||
let token = std::env::var(&cfg.api_token_env).with_context(|| format!("{} is not set", cfg.api_token_env))?;
|
||||
dns::cloudflare::Cloudflare::new(&cfg.api_base, token)
|
||||
}
|
||||
|
||||
async fn mta_sts_policy(domain: &str) -> Option<String> {
|
||||
let url = format!("https://mta-sts.{domain}/.well-known/mta-sts.txt");
|
||||
let r = http_client().ok()?.get(&url).send().await.ok()?;
|
||||
if !r.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
r.text().await.ok()
|
||||
}
|
||||
|
||||
async fn dns_cmd(cfg: &Config, plan_path: &Path, cmd: DnsCmd) -> Result<i32> {
|
||||
let dcfg = cfg.dns.as_ref().ok_or_else(|| anyhow!("[dns] section missing in config"))?;
|
||||
match cmd {
|
||||
DnsCmd::Zone { domain } => {
|
||||
for d in dns_domains(dcfg, plan_path, &domain)? {
|
||||
println!("{}", dns::render_zone(&desired_for(dcfg, &d)?, &d));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
DnsCmd::Verify { domain } => {
|
||||
let doh = dns::doh::Doh::new(&dcfg.doh_url)?;
|
||||
let mut bad = 0;
|
||||
for d in dns_domains(dcfg, plan_path, &domain)? {
|
||||
println!("== {d}");
|
||||
for (r, ok, seen) in doh.verify(&desired_for(dcfg, &d)?).await? {
|
||||
if ok {
|
||||
println!(" ok {}", r.display());
|
||||
} else {
|
||||
bad += 1;
|
||||
println!(" MISSING {}", r.display());
|
||||
for s in seen {
|
||||
println!(" public: {}", s.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(if bad > 0 { 1 } else { 0 })
|
||||
}
|
||||
DnsCmd::Plan { domain } => {
|
||||
let cf = cloudflare(dcfg)?;
|
||||
for d in dns_domains(dcfg, plan_path, &domain)? {
|
||||
let zone = cf.zone_id(&d).await?;
|
||||
let current = cf.list(&zone).await?;
|
||||
let p = dns::plan_domain(dcfg, &d, desired_for(dcfg, &d)?, ¤t);
|
||||
print_dns_plan(&p).await;
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
DnsCmd::Apply { domain, yes } => {
|
||||
if !yes {
|
||||
bail!("review `mailmig dns plan` first, then re-run with --yes");
|
||||
}
|
||||
let cf = cloudflare(dcfg)?;
|
||||
let mut failed = 0;
|
||||
for d in dns_domains(dcfg, plan_path, &domain)? {
|
||||
let zone = cf.zone_id(&d).await?;
|
||||
let current = cf.list(&zone).await?;
|
||||
let p = dns::plan_domain(dcfg, &d, desired_for(dcfg, &d)?, ¤t);
|
||||
print_dns_plan(&p).await;
|
||||
if p.changes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
std::fs::create_dir_all("dns-journal")?;
|
||||
let jpath = PathBuf::from(format!("dns-journal/{d}-{}.json", now_stamp()));
|
||||
let mut journal = serde_json::json!({
|
||||
"domain": d, "zone": zone, "before": current, "planned": p.changes, "applied": []
|
||||
});
|
||||
std::fs::write(&jpath, serde_json::to_string_pretty(&journal)?)?;
|
||||
let (applied, err) = match cf.apply(&zone, &p.changes).await {
|
||||
Ok(a) => (a, None),
|
||||
Err(e) => match e.downcast::<dns::cloudflare::ApplyError>() {
|
||||
Ok(ae) => (ae.done, Some(format!("{:#}", ae.source))),
|
||||
Err(e) => (vec![], Some(format!("{e:#}"))),
|
||||
},
|
||||
};
|
||||
journal["applied"] = serde_json::to_value(&applied)?;
|
||||
std::fs::write(&jpath, serde_json::to_string_pretty(&journal)?)?;
|
||||
println!(" journal: {0} (undo: mailmig dns rollback {0} --yes)", jpath.display());
|
||||
if let Some(e) = err {
|
||||
failed += 1;
|
||||
eprintln!(" {d}: apply stopped: {e}");
|
||||
}
|
||||
}
|
||||
Ok(if failed > 0 { 1 } else { 0 })
|
||||
}
|
||||
DnsCmd::Rollback { journal, yes } => {
|
||||
if !yes {
|
||||
bail!("re-run with --yes to undo {}", journal.display());
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&journal)?)?;
|
||||
let zone = v["zone"].as_str().ok_or_else(|| anyhow!("journal has no zone"))?;
|
||||
let applied: Vec<dns::cloudflare::Applied> = serde_json::from_value(v["applied"].clone())?;
|
||||
cloudflare(dcfg)?.rollback(zone, &applied).await?;
|
||||
println!("rolled back {} changes for {}", applied.len(), v["domain"].as_str().unwrap_or("?"));
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn print_dns_plan(p: &dns::DomainPlan) {
|
||||
println!("== {}", p.domain);
|
||||
if p.changes.is_empty() {
|
||||
println!(" (no changes)");
|
||||
}
|
||||
for c in &p.changes {
|
||||
for (i, l) in c.display().lines().enumerate() {
|
||||
println!(" {}{l}", if i == 0 { "" } else { " " });
|
||||
}
|
||||
}
|
||||
for r in &p.unchanged {
|
||||
println!(" = {}", r.display());
|
||||
}
|
||||
for w in &p.warnings {
|
||||
println!(" WARNING: {w}");
|
||||
if w.contains("MTA-STS") {
|
||||
match mta_sts_policy(&p.domain).await {
|
||||
Some(pol) => {
|
||||
println!(" current policy (https://mta-sts.{}/.well-known/mta-sts.txt):", p.domain);
|
||||
for l in pol.lines() {
|
||||
println!(" {l}");
|
||||
}
|
||||
}
|
||||
None => println!(" (policy file not reachable)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in &p.info {
|
||||
println!(" note: {i}");
|
||||
}
|
||||
}
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
//! The migration plan (`plan.toml`, human-reviewed) and the secrets that go
|
||||
//! with it (`secrets.json`, password hashes, never committed).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Plan {
|
||||
/// Where and when this plan was generated.
|
||||
#[serde(default)]
|
||||
pub generated: String,
|
||||
/// Things a human must look at before migrating.
|
||||
#[serde(default)]
|
||||
pub notes: Vec<String>,
|
||||
pub domains: Vec<String>,
|
||||
#[serde(rename = "account", default)]
|
||||
pub accounts: Vec<Account>,
|
||||
#[serde(rename = "forward", default)]
|
||||
pub forwards: Vec<Forward>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Account {
|
||||
/// Login (and primary address) on the destination.
|
||||
pub login: String,
|
||||
/// Login name on the source server.
|
||||
pub source_login: String,
|
||||
/// `individual` or `group` (shared mailbox on Stalwart).
|
||||
#[serde(default = "individual")]
|
||||
pub kind: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub aliases: Vec<String>,
|
||||
/// Dovecot quota, e.g. "10G". None = server default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub quota: Option<String>,
|
||||
/// Copy this account's mail (set false for accounts that only need to exist).
|
||||
#[serde(default = "yes")]
|
||||
pub migrate_mail: bool,
|
||||
/// How the password reaches the destination: `carried` (source hash is
|
||||
/// Dovecot-compatible), `plaintext` (source stored it in clear; mailmig
|
||||
/// hashed it), or `new` (needs a newly generated password).
|
||||
pub password: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
fn individual() -> String {
|
||||
"individual".into()
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Forward {
|
||||
pub address: String,
|
||||
pub to: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Secrets {
|
||||
pub accounts: BTreeMap<String, AccountSecret>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AccountSecret {
|
||||
/// Dovecot-ready hash (`{SCHEME}` prefix only where Dovecot needs it).
|
||||
pub hash: Option<String>,
|
||||
/// Plaintext password, if the source stored one.
|
||||
pub plaintext: Option<String>,
|
||||
/// What the source had, e.g. "SHA512-CRYPT" or "unsupported: $pbkdf2".
|
||||
pub source_scheme: String,
|
||||
#[serde(default)]
|
||||
pub app_passwords: usize,
|
||||
#[serde(default)]
|
||||
pub otp: bool,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
pub fn load(path: &Path) -> Result<Plan> {
|
||||
let s = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))
|
||||
}
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
let body = toml::to_string_pretty(self)?;
|
||||
let header = "# mailmig plan: review and edit before `mailmig users` / `mailmig sync`.\n\
|
||||
# Remove an [[account]] block (or set migrate_mail = false) to skip it.\n\n";
|
||||
std::fs::write(path, format!("{header}{body}")).with_context(|| format!("writing {}", path.display()))
|
||||
}
|
||||
pub fn account(&self, login: &str) -> Option<&Account> {
|
||||
self.accounts.iter().find(|a| a.login.eq_ignore_ascii_case(login) || a.source_login.eq_ignore_ascii_case(login))
|
||||
}
|
||||
}
|
||||
|
||||
impl Secrets {
|
||||
pub fn load(path: &Path) -> Result<Secrets> {
|
||||
if !path.exists() {
|
||||
return Ok(Secrets::default());
|
||||
}
|
||||
let s = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
serde_json::from_str(&s).with_context(|| format!("parsing {}", path.display()))
|
||||
}
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
write_private(path, serde_json::to_string_pretty(self)?.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a file readable only by the owner (on Unix).
|
||||
pub fn write_private(path: &Path, data: &[u8]) -> Result<()> {
|
||||
if let Some(dir) = path.parent()
|
||||
&& !dir.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
f.write_all(data)?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(path, data).with_context(|| format!("writing {}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a source secret means for Dovecot.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SecretKind {
|
||||
/// Usable as-is (or with the given prefix) in a Dovecot passwd file.
|
||||
Dovecot {
|
||||
scheme: &'static str,
|
||||
hash: String,
|
||||
},
|
||||
/// Stored in clear text on the source.
|
||||
Plaintext(String),
|
||||
AppPassword,
|
||||
Otp,
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
/// Classify one entry of a Stalwart `secrets` list.
|
||||
///
|
||||
/// SNM writes hashes without a `{SCHEME}` prefix as `{CRYPT}`, which Dovecot
|
||||
/// verifies with libxcrypt ($6$, $5$, $2b$, $y$, $1$). Anything else needs an
|
||||
/// explicit scheme Dovecot knows.
|
||||
pub fn classify_secret(s: &str) -> SecretKind {
|
||||
let s = s.trim();
|
||||
if s.starts_with("$app$") {
|
||||
return SecretKind::AppPassword;
|
||||
}
|
||||
if s.starts_with("otpauth://") {
|
||||
return SecretKind::Otp;
|
||||
}
|
||||
let crypt = [
|
||||
("$6$", "SHA512-CRYPT"),
|
||||
("$5$", "SHA256-CRYPT"),
|
||||
("$2b$", "BLF-CRYPT"),
|
||||
("$2y$", "BLF-CRYPT"),
|
||||
("$2a$", "BLF-CRYPT"),
|
||||
("$y$", "YESCRYPT"),
|
||||
("$1$", "MD5-CRYPT"),
|
||||
];
|
||||
for (p, scheme) in crypt {
|
||||
if s.starts_with(p) {
|
||||
return SecretKind::Dovecot { scheme, hash: s.to_string() };
|
||||
}
|
||||
}
|
||||
if s.starts_with("$argon2id$") {
|
||||
return SecretKind::Dovecot { scheme: "ARGON2ID", hash: format!("{{ARGON2ID}}{s}") };
|
||||
}
|
||||
if s.starts_with("$argon2i$") {
|
||||
return SecretKind::Dovecot { scheme: "ARGON2I", hash: format!("{{ARGON2I}}{s}") };
|
||||
}
|
||||
if s.starts_with('$') {
|
||||
let id = s.split('$').nth(1).unwrap_or("");
|
||||
return SecretKind::Unsupported(format!("${id}"));
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix('{')
|
||||
&& let Some(close) = rest.find('}')
|
||||
{
|
||||
let scheme = rest[..close].to_ascii_uppercase();
|
||||
let value = &rest[close + 1..];
|
||||
// Stalwart scheme -> Dovecot scheme. Stalwart's {MD5} is the
|
||||
// LDAP-style base64 digest, which Dovecot calls LDAP-MD5.
|
||||
let mapped: Option<&'static str> = match scheme.as_str() {
|
||||
"SHA" => Some("SHA"),
|
||||
"SSHA" => Some("SSHA"),
|
||||
"SHA256" => Some("SHA256"),
|
||||
"SSHA256" => Some("SSHA256"),
|
||||
"SHA512" => Some("SHA512"),
|
||||
"SSHA512" => Some("SSHA512"),
|
||||
"MD5" => Some("LDAP-MD5"),
|
||||
"SMD5" => Some("SMD5"),
|
||||
"CRYPT" => Some("CRYPT"),
|
||||
"PLAIN" | "CLEAR" => None,
|
||||
_ => return SecretKind::Unsupported(format!("{{{scheme}}}")),
|
||||
};
|
||||
return match mapped {
|
||||
Some(d) => SecretKind::Dovecot { scheme: d, hash: format!("{{{d}}}{value}") },
|
||||
None => SecretKind::Plaintext(value.to_string()),
|
||||
};
|
||||
}
|
||||
if s.is_empty() {
|
||||
return SecretKind::Unsupported("empty".into());
|
||||
}
|
||||
SecretKind::Plaintext(s.to_string())
|
||||
}
|
||||
|
||||
/// SHA512-crypt hash (what `mkpasswd -m sha-512` produces).
|
||||
pub fn sha512_crypt(password: &str) -> Result<String> {
|
||||
use sha_crypt::{PasswordHasher, ShaCrypt};
|
||||
let h =
|
||||
ShaCrypt::default().hash_password(password.as_bytes()).map_err(|e| anyhow::anyhow!("hashing password: {e}"))?;
|
||||
Ok(h.as_str().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn verify_sha_crypt(password: &str, hash: &str) -> bool {
|
||||
use sha_crypt::{PasswordHash, PasswordVerifier, ShaCrypt};
|
||||
match PasswordHash::new(hash) {
|
||||
Ok(h) => ShaCrypt::default().verify_password(password.as_bytes(), &h).is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Random password: 24 chars from an unambiguous alphabet (~140 bits).
|
||||
pub fn random_password() -> String {
|
||||
use rand::RngExt;
|
||||
const ALPHA: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let mut rng = rand::rng();
|
||||
(0..24).map(|_| ALPHA[rng.random_range(0..ALPHA.len())] as char).collect()
|
||||
}
|
||||
|
||||
/// Bytes → Dovecot quota string (rounded up to whole MiB/GiB).
|
||||
pub fn quota_string(bytes: u64) -> Option<String> {
|
||||
const M: u64 = 1024 * 1024;
|
||||
const G: u64 = 1024 * M;
|
||||
match bytes {
|
||||
0 => None,
|
||||
b if b % G == 0 => Some(format!("{}G", b / G)),
|
||||
b => Some(format!("{}M", b.div_ceil(M))),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Building a plan from a directory dump.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PrincipalKind {
|
||||
Individual,
|
||||
Group,
|
||||
List,
|
||||
}
|
||||
|
||||
/// Source directory entry, normalised across Stalwart API versions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Principal {
|
||||
pub kind: PrincipalKind,
|
||||
/// Login / principal name on the source.
|
||||
pub name: String,
|
||||
/// Addresses; the first is the primary one.
|
||||
pub emails: Vec<String>,
|
||||
pub secrets: Vec<String>,
|
||||
pub quota: Option<u64>,
|
||||
pub description: Option<String>,
|
||||
/// Lists: member principal names or addresses.
|
||||
#[serde(default)]
|
||||
pub members: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Directory {
|
||||
pub source: String,
|
||||
pub domains: Vec<String>,
|
||||
pub principals: Vec<Principal>,
|
||||
#[serde(default)]
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn build_plan(dir: &Directory, generated: String) -> (Plan, Secrets) {
|
||||
let mut plan = Plan { generated, notes: dir.notes.clone(), ..Default::default() };
|
||||
let mut secrets = Secrets::default();
|
||||
let mut domains: BTreeSet<String> = dir.domains.iter().map(|d| d.to_ascii_lowercase()).collect();
|
||||
let mut taken: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
let primary_of = |p: &Principal| -> Option<String> {
|
||||
let emails: Vec<String> = p.emails.iter().map(|e| e.trim().to_ascii_lowercase()).collect();
|
||||
if p.name.contains('@') && emails.iter().any(|e| e.eq_ignore_ascii_case(&p.name)) {
|
||||
return Some(p.name.to_ascii_lowercase());
|
||||
}
|
||||
emails.into_iter().find(|e| e.contains('@'))
|
||||
};
|
||||
|
||||
for p in dir.principals.iter().filter(|p| p.kind != PrincipalKind::List) {
|
||||
let Some(login) = primary_of(p) else {
|
||||
plan.notes.push(format!(
|
||||
"skipped {} '{}': no email address (admin/service account?)",
|
||||
kind_str(p.kind),
|
||||
p.name
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if !taken.insert(login.clone()) {
|
||||
plan.notes.push(format!("skipped duplicate address {login} (principal '{}')", p.name));
|
||||
continue;
|
||||
}
|
||||
let mut aliases: Vec<String> = Vec::new();
|
||||
for e in &p.emails {
|
||||
let e = e.trim().to_ascii_lowercase();
|
||||
if e != login && e.contains('@') && !aliases.contains(&e) && taken.insert(e.clone()) {
|
||||
aliases.push(e);
|
||||
}
|
||||
}
|
||||
for a in std::iter::once(&login).chain(aliases.iter()) {
|
||||
if let Some((_, d)) = a.rsplit_once('@') {
|
||||
domains.insert(d.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut sec = AccountSecret::default();
|
||||
let mut kinds = Vec::new();
|
||||
for s in &p.secrets {
|
||||
match classify_secret(s) {
|
||||
SecretKind::AppPassword => sec.app_passwords += 1,
|
||||
SecretKind::Otp => sec.otp = true,
|
||||
k => kinds.push(k),
|
||||
}
|
||||
}
|
||||
let password = match kinds.first() {
|
||||
Some(SecretKind::Dovecot { scheme, hash }) => {
|
||||
sec.hash = Some(hash.clone());
|
||||
sec.source_scheme = scheme.to_string();
|
||||
"carried"
|
||||
}
|
||||
Some(SecretKind::Plaintext(pw)) => {
|
||||
sec.plaintext = Some(pw.clone());
|
||||
sec.hash = sha512_crypt(pw).ok();
|
||||
sec.source_scheme = "plaintext".into();
|
||||
"plaintext"
|
||||
}
|
||||
Some(SecretKind::Unsupported(what)) => {
|
||||
sec.source_scheme = format!("unsupported: {what}");
|
||||
"new"
|
||||
}
|
||||
_ => {
|
||||
sec.source_scheme = "none".into();
|
||||
"new"
|
||||
}
|
||||
};
|
||||
if password == "new" && p.kind == PrincipalKind::Individual {
|
||||
plan.notes.push(format!(
|
||||
"{login}: password cannot be carried over ({}); a new one must be issued",
|
||||
sec.source_scheme
|
||||
));
|
||||
}
|
||||
if sec.app_passwords > 0 {
|
||||
plan.notes.push(format!(
|
||||
"{login}: has {} app password(s) on Stalwart; SNM has none, devices using them must switch to the account password",
|
||||
sec.app_passwords
|
||||
));
|
||||
}
|
||||
if sec.otp {
|
||||
plan.notes.push(format!("{login}: uses TOTP on Stalwart; SNM has no second factor"));
|
||||
}
|
||||
if p.kind == PrincipalKind::Group {
|
||||
plan.notes.push(format!(
|
||||
"{login}: Stalwart group (shared mailbox) becomes a normal SNM account; members must log in to it or use a forward instead"
|
||||
));
|
||||
}
|
||||
if !p.enabled {
|
||||
plan.notes.push(format!("{login}: disabled on the source"));
|
||||
}
|
||||
secrets.accounts.insert(login.clone(), sec);
|
||||
plan.accounts.push(Account {
|
||||
login,
|
||||
source_login: p.name.clone(),
|
||||
kind: kind_str(p.kind).into(),
|
||||
aliases,
|
||||
quota: p.quota.and_then(quota_string),
|
||||
migrate_mail: true,
|
||||
password: password.into(),
|
||||
description: p.description.clone().filter(|d| !d.is_empty()),
|
||||
});
|
||||
}
|
||||
|
||||
// Lists become forwards. Members are principal names or addresses.
|
||||
for p in dir.principals.iter().filter(|p| p.kind == PrincipalKind::List) {
|
||||
let Some(addr) = primary_of(p) else {
|
||||
plan.notes.push(format!("skipped list '{}': no address", p.name));
|
||||
continue;
|
||||
};
|
||||
let mut to = Vec::new();
|
||||
for m in &p.members {
|
||||
let resolved = if m.contains('@') {
|
||||
Some(m.to_ascii_lowercase())
|
||||
} else {
|
||||
dir.principals.iter().find(|q| q.name.eq_ignore_ascii_case(m)).and_then(primary_of)
|
||||
};
|
||||
match resolved {
|
||||
Some(r) if !to.contains(&r) => to.push(r),
|
||||
Some(_) => {}
|
||||
None => plan.notes.push(format!("list {addr}: member '{m}' has no address, dropped")),
|
||||
}
|
||||
}
|
||||
let addrs: Vec<String> =
|
||||
std::iter::once(addr).chain(p.emails.iter().map(|e| e.to_ascii_lowercase())).collect::<Vec<_>>();
|
||||
let mut seen = BTreeSet::new();
|
||||
for a in addrs {
|
||||
if !seen.insert(a.clone()) {
|
||||
continue;
|
||||
}
|
||||
if !taken.insert(a.clone()) {
|
||||
plan.notes.push(format!("list address {a} collides with an account address, skipped"));
|
||||
continue;
|
||||
}
|
||||
if let Some((_, d)) = a.rsplit_once('@') {
|
||||
domains.insert(d.to_string());
|
||||
}
|
||||
if to.is_empty() {
|
||||
plan.notes.push(format!("list {a} has no members, skipped"));
|
||||
continue;
|
||||
}
|
||||
plan.forwards.push(Forward { address: a, to: to.clone(), description: p.description.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
plan.accounts.sort_by(|a, b| a.login.cmp(&b.login));
|
||||
plan.forwards.sort_by(|a, b| a.address.cmp(&b.address));
|
||||
plan.domains = domains.into_iter().collect();
|
||||
(plan, secrets)
|
||||
}
|
||||
|
||||
fn kind_str(k: PrincipalKind) -> &'static str {
|
||||
match k {
|
||||
PrincipalKind::Individual => "individual",
|
||||
PrincipalKind::Group => "group",
|
||||
PrincipalKind::List => "list",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classify() {
|
||||
assert!(matches!(classify_secret("$6$abc$def"), SecretKind::Dovecot { scheme: "SHA512-CRYPT", .. }));
|
||||
assert!(matches!(classify_secret("$2y$10$x"), SecretKind::Dovecot { scheme: "BLF-CRYPT", .. }));
|
||||
assert_eq!(
|
||||
classify_secret("$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA"),
|
||||
SecretKind::Dovecot {
|
||||
scheme: "ARGON2ID",
|
||||
hash: "{ARGON2ID}$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(classify_secret("{SSHA}abc="), SecretKind::Dovecot { scheme: "SSHA", hash: "{SSHA}abc=".into() });
|
||||
assert_eq!(
|
||||
classify_secret("{MD5}abc="),
|
||||
SecretKind::Dovecot { scheme: "LDAP-MD5", hash: "{LDAP-MD5}abc=".into() }
|
||||
);
|
||||
assert_eq!(classify_secret("{PLAIN}secret"), SecretKind::Plaintext("secret".into()));
|
||||
assert_eq!(classify_secret("hunter2"), SecretKind::Plaintext("hunter2".into()));
|
||||
assert_eq!(classify_secret("$pbkdf2-sha256$i=1$x$y"), SecretKind::Unsupported("$pbkdf2-sha256".into()));
|
||||
assert_eq!(classify_secret("$app$phone$hash"), SecretKind::AppPassword);
|
||||
assert_eq!(classify_secret("otpauth://totp/x"), SecretKind::Otp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_roundtrip() {
|
||||
let h = sha512_crypt("correct horse").unwrap();
|
||||
assert!(h.starts_with("$6$"));
|
||||
assert!(verify_sha_crypt("correct horse", &h));
|
||||
assert!(!verify_sha_crypt("wrong", &h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotas() {
|
||||
assert_eq!(quota_string(0), None);
|
||||
assert_eq!(quota_string(2 * 1024 * 1024 * 1024), Some("2G".into()));
|
||||
assert_eq!(quota_string(500 * 1024 * 1024), Some("500M".into()));
|
||||
assert_eq!(quota_string(1), Some("1M".into()));
|
||||
}
|
||||
|
||||
fn p(kind: PrincipalKind, name: &str, emails: &[&str], secrets: &[&str]) -> Principal {
|
||||
Principal {
|
||||
kind,
|
||||
name: name.into(),
|
||||
emails: emails.iter().map(|s| s.to_string()).collect(),
|
||||
secrets: secrets.iter().map(|s| s.to_string()).collect(),
|
||||
quota: None,
|
||||
description: None,
|
||||
members: vec![],
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_from_directory() {
|
||||
let mut list = p(PrincipalKind::List, "team", &["team@b4l.co.th"], &[]);
|
||||
list.members = vec!["alice".into(), "ext@gmail.com".into(), "ghost".into()];
|
||||
let mut alice =
|
||||
p(PrincipalKind::Individual, "alice", &["Alice@b4l.co.th", "a@buildfor.life"], &["$6$s$h", "$app$phone$x"]);
|
||||
alice.quota = Some(1024 * 1024 * 1024);
|
||||
let dir = Directory {
|
||||
source: "test".into(),
|
||||
domains: vec!["b4l.co.th".into(), "poyrecords.com".into()],
|
||||
principals: vec![
|
||||
alice,
|
||||
p(PrincipalKind::Individual, "bob@b4l.co.th", &["bob@b4l.co.th"], &["bobpass"]),
|
||||
p(PrincipalKind::Individual, "carol", &["carol@b4l.co.th"], &["$pbkdf2$x"]),
|
||||
p(PrincipalKind::Individual, "admin", &[], &["x"]),
|
||||
list,
|
||||
],
|
||||
notes: vec![],
|
||||
};
|
||||
let (plan, sec) = build_plan(&dir, "t".into());
|
||||
assert_eq!(plan.domains, vec!["b4l.co.th", "buildfor.life", "poyrecords.com"]);
|
||||
let logins: Vec<_> = plan.accounts.iter().map(|a| a.login.as_str()).collect();
|
||||
assert_eq!(logins, vec!["alice@b4l.co.th", "bob@b4l.co.th", "carol@b4l.co.th"]);
|
||||
let alice = plan.account("alice").unwrap();
|
||||
assert_eq!(alice.aliases, vec!["a@buildfor.life"]);
|
||||
assert_eq!(alice.quota.as_deref(), Some("1G"));
|
||||
assert_eq!(alice.password, "carried");
|
||||
assert_eq!(sec.accounts["alice@b4l.co.th"].hash.as_deref(), Some("$6$s$h"));
|
||||
assert_eq!(sec.accounts["alice@b4l.co.th"].app_passwords, 1);
|
||||
let bob = &sec.accounts["bob@b4l.co.th"];
|
||||
assert_eq!(bob.plaintext.as_deref(), Some("bobpass"));
|
||||
assert!(verify_sha_crypt("bobpass", bob.hash.as_deref().unwrap()));
|
||||
assert_eq!(plan.account("carol").unwrap().password, "new");
|
||||
assert_eq!(plan.forwards.len(), 1);
|
||||
assert_eq!(plan.forwards[0].to, vec!["alice@b4l.co.th", "ext@gmail.com"]);
|
||||
assert!(plan.notes.iter().any(|n| n.contains("'admin'")));
|
||||
assert!(plan.notes.iter().any(|n| n.contains("ghost")));
|
||||
// round-trips through TOML
|
||||
let s = toml::to_string_pretty(&plan).unwrap();
|
||||
let back: Plan = toml::from_str(&s).unwrap();
|
||||
assert_eq!(back.accounts, plan.accounts);
|
||||
assert_eq!(back.forwards, plan.forwards);
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
//! `mailmig users`: render the destination side for Simple NixOS Mailserver.
|
||||
|
||||
use crate::config::SnmCfg;
|
||||
use crate::plan::{Plan, Secrets, random_password, sha512_crypt, write_private};
|
||||
use anyhow::{Context, Result};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn nix_str(s: &str) -> String {
|
||||
let mut o = String::from("\"");
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => o.push_str("\\\""),
|
||||
'\\' => o.push_str("\\\\"),
|
||||
'\n' => o.push_str("\\n"),
|
||||
'$' if chars.peek() == Some(&'{') => o.push_str("\\$"),
|
||||
c => o.push(c),
|
||||
}
|
||||
}
|
||||
o.push('"');
|
||||
o
|
||||
}
|
||||
|
||||
fn nix_list(items: &[String], indent: &str) -> String {
|
||||
if items.is_empty() {
|
||||
return "[ ]".into();
|
||||
}
|
||||
let mut o = String::from("[\n");
|
||||
for i in items {
|
||||
let _ = writeln!(o, "{indent} {}", nix_str(i));
|
||||
}
|
||||
o.push_str(indent);
|
||||
o.push(']');
|
||||
o
|
||||
}
|
||||
|
||||
/// Clan vars generator name for an address (as in cnx-network-clan's modules/mail.nix).
|
||||
pub fn generator_name(prefix: &str, addr: &str) -> String {
|
||||
format!("{prefix}{}", addr.replace('@', "-at-").replace('.', "-"))
|
||||
}
|
||||
|
||||
/// Pure data: domains, accounts (aliases/quota), forwards. The consuming
|
||||
/// NixOS module decides where password hashes come from.
|
||||
pub fn render_accounts_nix(plan: &Plan) -> String {
|
||||
let mut o = String::new();
|
||||
o.push_str("# Generated by `mailmig users` from the Stalwart inventory. Do not edit by hand;\n");
|
||||
o.push_str("# edit plan.toml and re-run. Password hashes are NOT in here (see hashes/).\n");
|
||||
if !plan.generated.is_empty() {
|
||||
let _ = writeln!(o, "# Source: {}", plan.generated);
|
||||
}
|
||||
o.push_str("{\n");
|
||||
let _ = writeln!(o, " domains = {};", nix_list(&plan.domains, " "));
|
||||
o.push_str("\n accounts = {\n");
|
||||
for a in &plan.accounts {
|
||||
let _ = writeln!(o, " {} = {{", nix_str(&a.login));
|
||||
if let Some(d) = &a.description {
|
||||
let _ = writeln!(o, " # {}", d.replace('\n', " "));
|
||||
}
|
||||
let _ = writeln!(o, " # password: {}; kind: {}", a.password, a.kind);
|
||||
let _ = writeln!(o, " aliases = {};", nix_list(&a.aliases, " "));
|
||||
if let Some(q) = &a.quota {
|
||||
let _ = writeln!(o, " quota = {};", nix_str(q));
|
||||
}
|
||||
o.push_str(" };\n");
|
||||
}
|
||||
o.push_str(" };\n\n forwards = {\n");
|
||||
for f in &plan.forwards {
|
||||
let _ = writeln!(o, " {} = {};", nix_str(&f.address), nix_list(&f.to, " "));
|
||||
}
|
||||
o.push_str(" };\n}\n");
|
||||
o
|
||||
}
|
||||
|
||||
/// Temporary Dovecot master user so mailmig can open every mailbox as
|
||||
/// `<account>*<master>`. Dovecot 2.4 settings syntax (SNM 26.05).
|
||||
pub fn render_master_user_nix(master: &str) -> String {
|
||||
format!(
|
||||
r#"# TEMPORARY: Dovecot master user for the mail migration (mailmig).
|
||||
# Lets "<account>*{master}" log in to any mailbox with the master password.
|
||||
# Import this module on the SNM host only while migrating, then remove it.
|
||||
#
|
||||
# Create the passwd file on the host (root, 0640 root:dovecot2):
|
||||
# printf '{master}:{{CRYPT}}%s\n' "$(mkpasswd -m sha-512)" > /var/lib/dovecot-master-users
|
||||
{{ ... }}:
|
||||
{{
|
||||
services.dovecot2.settings = {{
|
||||
auth_master_user_separator = "*";
|
||||
"passdb mailmig-master" = {{
|
||||
driver = "passwd-file";
|
||||
passwd_file_path = "/var/lib/dovecot-master-users";
|
||||
master = true;
|
||||
# After the master password checks out, still require the target
|
||||
# account to exist in SNM's own passdb.
|
||||
result_success = "continue";
|
||||
}};
|
||||
}};
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
pub struct UsersOutput {
|
||||
pub hashes_written: usize,
|
||||
pub generated: Vec<String>,
|
||||
pub missing: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn write_users(
|
||||
plan: &Plan,
|
||||
secrets: &Secrets,
|
||||
snm: &SnmCfg,
|
||||
out: &Path,
|
||||
generate_missing: bool,
|
||||
) -> Result<UsersOutput> {
|
||||
std::fs::create_dir_all(out.join("hashes")).with_context(|| format!("creating {}", out.display()))?;
|
||||
std::fs::write(out.join("mail-accounts.nix"), render_accounts_nix(plan))?;
|
||||
std::fs::write(out.join("dovecot-master-user.nix"), render_master_user_nix(&snm.master_user))?;
|
||||
|
||||
let mut script = String::from(
|
||||
"#!/usr/bin/env bash\n\
|
||||
# Load migrated password hashes into clan vars (run from the clan repo).\n\
|
||||
# Each hash replaces the generator's random passphrase hash, so users keep\n\
|
||||
# their Stalwart password. Generated by `mailmig users`.\n\
|
||||
set -euo pipefail\n\
|
||||
HERE=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n",
|
||||
);
|
||||
let mut res = UsersOutput { hashes_written: 0, generated: vec![], missing: vec![] };
|
||||
let mut generated_lines = String::new();
|
||||
for a in &plan.accounts {
|
||||
let sec = secrets.accounts.get(&a.login);
|
||||
let mut hash = sec.and_then(|s| s.hash.clone());
|
||||
if hash.is_none() && generate_missing {
|
||||
let pw = random_password();
|
||||
hash = Some(sha512_crypt(&pw)?);
|
||||
let _ = writeln!(generated_lines, "{}:{}", a.login, pw);
|
||||
res.generated.push(a.login.clone());
|
||||
}
|
||||
match hash {
|
||||
Some(h) => {
|
||||
write_private(&out.join("hashes").join(format!("{}.hash", a.login)), format!("{h}\n").as_bytes())?;
|
||||
res.hashes_written += 1;
|
||||
let _ = writeln!(
|
||||
script,
|
||||
"clan vars set {} {}/hash < \"$HERE/hashes/{}.hash\"",
|
||||
snm.clan_machine,
|
||||
generator_name(&snm.clan_generator_prefix, &a.login),
|
||||
a.login
|
||||
);
|
||||
}
|
||||
None => res.missing.push(a.login.clone()),
|
||||
}
|
||||
}
|
||||
if !res.missing.is_empty() {
|
||||
script.push_str("\n# No usable hash (generator keeps its random passphrase; hand it out):\n");
|
||||
for m in &res.missing {
|
||||
let _ = writeln!(
|
||||
script,
|
||||
"# clan vars get {} {}/passphrase",
|
||||
snm.clan_machine,
|
||||
generator_name(&snm.clan_generator_prefix, m)
|
||||
);
|
||||
}
|
||||
}
|
||||
write_private(&out.join("clan-vars-set.sh"), script.as_bytes())?;
|
||||
if !generated_lines.is_empty() {
|
||||
// Same `login:password` format `auth.mode = "password"` reads.
|
||||
write_private(&out.join("generated-passwords.txt"), generated_lines.as_bytes())?;
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plan::{Account, AccountSecret, Forward};
|
||||
|
||||
fn plan() -> Plan {
|
||||
Plan {
|
||||
generated: "test".into(),
|
||||
notes: vec![],
|
||||
domains: vec!["b4l.co.th".into()],
|
||||
accounts: vec![
|
||||
Account {
|
||||
login: "alice@b4l.co.th".into(),
|
||||
source_login: "alice".into(),
|
||||
kind: "individual".into(),
|
||||
aliases: vec!["a@b4l.co.th".into()],
|
||||
quota: Some("2G".into()),
|
||||
migrate_mail: true,
|
||||
password: "carried".into(),
|
||||
description: Some("Alice \"A\"".into()),
|
||||
},
|
||||
Account {
|
||||
login: "carol@b4l.co.th".into(),
|
||||
source_login: "carol".into(),
|
||||
kind: "individual".into(),
|
||||
aliases: vec![],
|
||||
quota: None,
|
||||
migrate_mail: true,
|
||||
password: "new".into(),
|
||||
description: None,
|
||||
},
|
||||
],
|
||||
forwards: vec![Forward {
|
||||
address: "team@b4l.co.th".into(),
|
||||
to: vec!["alice@b4l.co.th".into()],
|
||||
description: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nix_rendering() {
|
||||
let s = render_accounts_nix(&plan());
|
||||
assert!(s.contains("\"alice@b4l.co.th\" = {"));
|
||||
assert!(s.contains("quota = \"2G\";"));
|
||||
assert!(s.contains("\"team@b4l.co.th\" = [\n \"alice@b4l.co.th\"\n ];"));
|
||||
assert_eq!(nix_str("a${b}\"c"), "\"a\\${b}\\\"c\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_names() {
|
||||
assert_eq!(generator_name("mail-passwd-", "postmaster@cnx.email"), "mail-passwd-postmaster-at-cnx-email");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn users_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut sec = Secrets::default();
|
||||
sec.accounts
|
||||
.insert("alice@b4l.co.th".into(), AccountSecret { hash: Some("$6$s$h".into()), ..Default::default() });
|
||||
let r = write_users(&plan(), &sec, &SnmCfg::default(), dir.path(), false).unwrap();
|
||||
assert_eq!(r.hashes_written, 1);
|
||||
assert_eq!(r.missing, vec!["carol@b4l.co.th"]);
|
||||
let h = std::fs::read_to_string(dir.path().join("hashes/alice@b4l.co.th.hash")).unwrap();
|
||||
assert_eq!(h, "$6$s$h\n");
|
||||
let script = std::fs::read_to_string(dir.path().join("clan-vars-set.sh")).unwrap();
|
||||
assert!(script.contains("clan vars set mx1 mail-passwd-alice-at-b4l-co-th/hash"));
|
||||
assert!(script.contains("clan vars get mx1 mail-passwd-carol-at-b4l-co-th/passphrase"));
|
||||
|
||||
let r = write_users(&plan(), &sec, &SnmCfg::default(), dir.path(), true).unwrap();
|
||||
assert_eq!(r.generated, vec!["carol@b4l.co.th"]);
|
||||
let gen_pw = std::fs::read_to_string(dir.path().join("generated-passwords.txt")).unwrap();
|
||||
let (_, pw) = gen_pw.trim().split_once(':').unwrap();
|
||||
let h = std::fs::read_to_string(dir.path().join("hashes/carol@b4l.co.th.hash")).unwrap();
|
||||
assert!(crate::plan::verify_sha_crypt(pw, h.trim()));
|
||||
}
|
||||
}
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
//! Read the account directory from Stalwart's management API.
|
||||
//!
|
||||
//! * Stalwart <= 0.15: REST, `GET /api/principal?types=..` + `GET /api/principal/{name}`.
|
||||
//! * Stalwart >= 0.16: JMAP at `POST /api` with `urn:stalwart:jmap`
|
||||
//! (`x:Domain`, `x:Account`, `x:MailingList`).
|
||||
|
||||
use crate::config::{ApiKind, StalwartApi};
|
||||
use crate::plan::{Directory, Principal, PrincipalKind};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub struct Api {
|
||||
http: reqwest::Client,
|
||||
base: String,
|
||||
auth: AuthHeader,
|
||||
}
|
||||
|
||||
enum AuthHeader {
|
||||
Basic(String, String),
|
||||
Bearer(String),
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub fn new(cfg: &StalwartApi) -> Result<Api> {
|
||||
let auth = if let Some(env) = &cfg.token_env {
|
||||
AuthHeader::Bearer(std::env::var(env).with_context(|| format!("{env} is not set"))?)
|
||||
} else {
|
||||
let user = cfg.user.clone().ok_or_else(|| anyhow!("[stalwart] needs user (+ password) or token_env"))?;
|
||||
AuthHeader::Basic(user, cfg.secret().resolve("[stalwart] password")?)
|
||||
};
|
||||
Ok(Api { http: crate::http_client()?, base: cfg.url.trim_end_matches('/').to_string(), auth })
|
||||
}
|
||||
|
||||
fn req(&self, m: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
|
||||
let r = self.http.request(m, format!("{}{}", self.base, path)).header("Accept", "application/json");
|
||||
match &self.auth {
|
||||
AuthHeader::Basic(u, p) => r.basic_auth(u, Some(p)),
|
||||
AuthHeader::Bearer(t) => r.bearer_auth(t),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(&self, rb: reqwest::RequestBuilder, what: &str) -> Result<Value> {
|
||||
let resp = rb.send().await.with_context(|| format!("{what}: request failed"))?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
bail!("{what}: HTTP {status}: {}", truncate(&body, 300));
|
||||
}
|
||||
serde_json::from_str(&body).with_context(|| format!("{what}: not JSON: {}", truncate(&body, 200)))
|
||||
}
|
||||
|
||||
pub async fn detect(&self, kind: ApiKind) -> Result<ApiKind> {
|
||||
if kind != ApiKind::Auto {
|
||||
return Ok(kind);
|
||||
}
|
||||
let session = self.send(self.req(reqwest::Method::GET, "/.well-known/jmap"), "JMAP session").await;
|
||||
Ok(match session {
|
||||
Ok(s) if s.get("capabilities").and_then(|c| c.get("urn:stalwart:jmap")).is_some() => ApiKind::Jmap,
|
||||
_ => ApiKind::Rest,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn directory(&self, kind: ApiKind) -> Result<(Directory, Value)> {
|
||||
let kind = self.detect(kind).await?;
|
||||
tracing::info!("Stalwart API: {}", if kind == ApiKind::Jmap { "JMAP (>= 0.16)" } else { "REST (<= 0.15)" });
|
||||
match kind {
|
||||
ApiKind::Jmap => self.directory_jmap().await,
|
||||
_ => self.directory_rest().await,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- REST (<= 0.15) -----------------------------------------------------
|
||||
|
||||
async fn rest_get(&self, path: &str, query: &[(&str, String)]) -> Result<Value> {
|
||||
let v = self.send(self.req(reqwest::Method::GET, path).query(query), path).await?;
|
||||
if let Some(e) = v.get("error")
|
||||
&& v.get("data").is_none()
|
||||
{
|
||||
bail!("{path}: server error: {e}");
|
||||
}
|
||||
Ok(v.get("data").cloned().unwrap_or(v))
|
||||
}
|
||||
|
||||
async fn directory_rest(&self) -> Result<(Directory, Value)> {
|
||||
let mut raw = Vec::new();
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for t in ["individual", "group", "list", "domain"] {
|
||||
let mut page = 1;
|
||||
let mut seen = 0usize;
|
||||
loop {
|
||||
let data = self
|
||||
.rest_get(
|
||||
"/api/principal",
|
||||
&[("types", t.to_string()), ("page", page.to_string()), ("limit", "200".into())],
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("listing {t} principals"))?;
|
||||
let items = data.get("items").and_then(|i| i.as_array()).cloned().unwrap_or_default();
|
||||
let total = data.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;
|
||||
seen += items.len();
|
||||
for it in &items {
|
||||
// List items are either names or (newer) partial objects.
|
||||
let n = if it.is_string() { it.as_str().map(String::from) } else { pv_string(it.get("name")) };
|
||||
if let Some(n) = n.filter(|n| !n.is_empty())
|
||||
&& !names.contains(&n)
|
||||
{
|
||||
names.push(n);
|
||||
}
|
||||
}
|
||||
if items.is_empty() || seen >= total || page > 1000 {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
for n in &names {
|
||||
let enc = url_escape(n);
|
||||
let full =
|
||||
self.rest_get(&format!("/api/principal/{enc}"), &[]).await.with_context(|| format!("principal {n}"))?;
|
||||
raw.push(full);
|
||||
}
|
||||
let mut dir = rest_to_directory(&raw);
|
||||
dir.source = format!("{} (REST)", self.base);
|
||||
// Catch-all and sub-addressing live in settings, not principals.
|
||||
if let Ok(s) = self.rest_get("/api/settings/list", &[("prefix", "session.rcpt".into())]).await
|
||||
&& let Some(items) = s.get("items").and_then(|i| i.as_object())
|
||||
{
|
||||
for (k, v) in items {
|
||||
if k.contains("catch-all") || k.contains("sub-addressing") {
|
||||
dir.notes.push(format!("source setting {k} = {v} (recreate on SNM by hand if needed)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((dir, Value::Array(raw)))
|
||||
}
|
||||
|
||||
// ---- JMAP (>= 0.16) -----------------------------------------------------
|
||||
|
||||
async fn jmap_all(&self, object: &str) -> Result<Vec<Value>> {
|
||||
let mut out = Vec::new();
|
||||
let mut position = 0u64;
|
||||
loop {
|
||||
let body = json!({
|
||||
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
|
||||
"methodCalls": [
|
||||
[format!("x:{object}/query"), {"position": position, "limit": 500, "calculateTotal": true}, "q"],
|
||||
[format!("x:{object}/get"), {"#ids": {"resultOf": "q", "name": format!("x:{object}/query"), "path": "/ids"}}, "g"]
|
||||
]
|
||||
});
|
||||
let v = self.send(self.req(reqwest::Method::POST, "/api").json(&body), &format!("x:{object}")).await?;
|
||||
let responses = v
|
||||
.get("methodResponses")
|
||||
.and_then(|m| m.as_array())
|
||||
.ok_or_else(|| anyhow!("x:{object}: no methodResponses"))?;
|
||||
let mut ids = 0u64;
|
||||
let mut total = None;
|
||||
for r in responses {
|
||||
let name = r.get(0).and_then(|n| n.as_str()).unwrap_or("");
|
||||
let args = r.get(1).cloned().unwrap_or(Value::Null);
|
||||
if name == "error" {
|
||||
bail!("x:{object}: {args}");
|
||||
}
|
||||
if name.ends_with("/query") {
|
||||
ids = args.get("ids").and_then(|i| i.as_array()).map(|a| a.len() as u64).unwrap_or(0);
|
||||
total = args.get("total").and_then(|t| t.as_u64());
|
||||
}
|
||||
if name.ends_with("/get") {
|
||||
out.extend(args.get("list").and_then(|l| l.as_array()).cloned().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
position += ids;
|
||||
if ids == 0 || total.is_none_or(|t| position >= t) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn directory_jmap(&self) -> Result<(Directory, Value)> {
|
||||
let domains = self.jmap_all("Domain").await?;
|
||||
let accounts = self.jmap_all("Account").await?;
|
||||
let lists = self.jmap_all("MailingList").await.unwrap_or_else(|e| {
|
||||
tracing::warn!("mailing lists not readable: {e:#}");
|
||||
Vec::new()
|
||||
});
|
||||
let mut dir = jmap_to_directory(&domains, &accounts, &lists);
|
||||
dir.source = format!("{} (JMAP)", self.base);
|
||||
let raw = json!({"Domain": domains, "Account": accounts, "MailingList": lists});
|
||||
Ok((dir, raw))
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.len() <= n { s.to_string() } else { format!("{}…", &s[..s.floor_char_boundary(n)]) }
|
||||
}
|
||||
|
||||
fn url_escape(s: &str) -> String {
|
||||
let mut o = String::new();
|
||||
for b in s.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
|
||||
o.push(b as char);
|
||||
} else {
|
||||
o.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
// Stalwart 0.15 values come either plain or wrapped ({"string": ..},
|
||||
// {"stringList": [..]}); accept both, as Stalwart's own migration script does.
|
||||
fn pv_string(v: Option<&Value>) -> Option<String> {
|
||||
match v? {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
Value::Object(o) => o
|
||||
.get("string")
|
||||
.and_then(|s| s.as_str().map(String::from))
|
||||
.or_else(|| o.get("stringList").and_then(|l| l.get(0)).and_then(|s| s.as_str().map(String::from))),
|
||||
Value::Array(a) => a.first().and_then(|x| pv_string(Some(x))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pv_list(v: Option<&Value>) -> Vec<String> {
|
||||
match v {
|
||||
None | Some(Value::Null) => vec![],
|
||||
Some(Value::String(s)) => vec![s.clone()],
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|x| pv_string(Some(x))).collect(),
|
||||
Some(Value::Object(o)) => {
|
||||
if let Some(l) = o.get("stringList").and_then(|l| l.as_array()) {
|
||||
l.iter().filter_map(|x| x.as_str().map(String::from)).collect()
|
||||
} else {
|
||||
pv_string(Some(&Value::Object(o.clone()))).into_iter().collect()
|
||||
}
|
||||
}
|
||||
Some(other) => pv_string(Some(other)).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pv_u64(v: Option<&Value>) -> Option<u64> {
|
||||
match v? {
|
||||
Value::Number(n) => n.as_u64(),
|
||||
Value::String(s) => s.parse().ok(),
|
||||
Value::Object(o) => o.get("integer").and_then(|i| i.as_u64()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rest_to_directory(raw: &[Value]) -> Directory {
|
||||
let mut dir = Directory::default();
|
||||
// Member references may be names or numeric principal ids.
|
||||
let by_id: BTreeMap<String, String> =
|
||||
raw.iter().filter_map(|p| Some((pv_u64(p.get("id"))?.to_string(), pv_string(p.get("name"))?))).collect();
|
||||
let resolve = |m: String| by_id.get(&m).cloned().unwrap_or(m);
|
||||
for p in raw {
|
||||
let typ = p.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
let name = pv_string(p.get("name")).unwrap_or_default();
|
||||
let kind = match typ {
|
||||
"domain" => {
|
||||
if !name.is_empty() {
|
||||
dir.domains.push(name.to_ascii_lowercase());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
"individual" => PrincipalKind::Individual,
|
||||
"group" => PrincipalKind::Group,
|
||||
"list" => PrincipalKind::List,
|
||||
_ => continue,
|
||||
};
|
||||
let mut members: Vec<String> = pv_list(p.get("members")).into_iter().map(resolve).collect();
|
||||
members.extend(pv_list(p.get("externalMembers")));
|
||||
dir.principals.push(Principal {
|
||||
kind,
|
||||
name,
|
||||
emails: pv_list(p.get("emails")),
|
||||
secrets: pv_list(p.get("secrets")),
|
||||
quota: pv_u64(p.get("quota")),
|
||||
description: pv_string(p.get("description")),
|
||||
members,
|
||||
enabled: !p.get("enabled").and_then(|e| e.as_bool()).is_some_and(|e| !e),
|
||||
});
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
/// JMAP "lists" arrive as arrays or as index-keyed maps; iterate both.
|
||||
fn listish(v: Option<&Value>) -> Vec<&Value> {
|
||||
match v {
|
||||
Some(Value::Array(a)) => a.iter().collect(),
|
||||
Some(Value::Object(o)) => o.values().collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jmap_to_directory(domains: &[Value], accounts: &[Value], lists: &[Value]) -> Directory {
|
||||
let mut dir = Directory::default();
|
||||
let mut dom: BTreeMap<String, String> = BTreeMap::new();
|
||||
for d in domains {
|
||||
if let (Some(id), Some(name)) = (d.get("id").and_then(|i| i.as_str()), d.get("name").and_then(|n| n.as_str())) {
|
||||
dom.insert(id.to_string(), name.to_ascii_lowercase());
|
||||
dir.domains.push(name.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
let addr = |local: Option<&Value>, domain_id: Option<&Value>| -> Option<String> {
|
||||
let l = local?.as_str()?;
|
||||
let d = dom.get(domain_id?.as_str()?)?;
|
||||
Some(format!("{l}@{d}"))
|
||||
};
|
||||
let aliases_of = |v: &Value| -> Vec<String> {
|
||||
listish(v.get("aliases"))
|
||||
.into_iter()
|
||||
.filter(|a| a.get("enabled").and_then(|e| e.as_bool()) != Some(false))
|
||||
.filter_map(|a| addr(a.get("name"), a.get("domainId")))
|
||||
.collect()
|
||||
};
|
||||
for a in accounts {
|
||||
let kind = match a.get("@type").and_then(|t| t.as_str()) {
|
||||
Some("Group") => PrincipalKind::Group,
|
||||
_ => PrincipalKind::Individual,
|
||||
};
|
||||
let primary = a
|
||||
.get("emailAddress")
|
||||
.and_then(|e| e.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| addr(a.get("name"), a.get("domainId")));
|
||||
let Some(primary) = primary else {
|
||||
dir.notes.push(format!("JMAP account {} has no address, skipped", a.get("id").unwrap_or(&Value::Null)));
|
||||
continue;
|
||||
};
|
||||
let mut emails = vec![primary.clone()];
|
||||
emails.extend(aliases_of(a));
|
||||
let mut secrets = Vec::new();
|
||||
for c in listish(a.get("credentials")) {
|
||||
let t = c.get("@type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
let secret = c.get("secret").and_then(|s| s.as_str()).unwrap_or("");
|
||||
match t {
|
||||
"Password" if !secret.is_empty() && !secret.chars().all(|ch| ch == '*') => {
|
||||
secrets.push(secret.to_string());
|
||||
if c.get("otpAuth").is_some_and(|o| !o.is_null()) {
|
||||
secrets.push("otpauth://(redacted)".into());
|
||||
}
|
||||
}
|
||||
"Password" => {}
|
||||
"AppPassword" => secrets.push("$app$(redacted)".into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
dir.principals.push(Principal {
|
||||
kind,
|
||||
name: primary,
|
||||
emails,
|
||||
secrets,
|
||||
quota: a.get("quotas").and_then(|q| q.get("maxDiskQuota")).and_then(|q| q.as_u64()),
|
||||
description: a.get("description").and_then(|d| d.as_str()).map(String::from),
|
||||
members: vec![],
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
for l in lists {
|
||||
let Some(primary) = addr(l.get("name"), l.get("domainId")) else { continue };
|
||||
let mut emails = vec![primary.clone()];
|
||||
emails.extend(aliases_of(l));
|
||||
let members = match l.get("recipients") {
|
||||
Some(Value::Object(o)) => o.keys().cloned().collect(),
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str().map(String::from)).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
dir.principals.push(Principal {
|
||||
kind: PrincipalKind::List,
|
||||
name: primary,
|
||||
emails,
|
||||
secrets: vec![],
|
||||
quota: None,
|
||||
description: l.get("description").and_then(|d| d.as_str()).map(String::from),
|
||||
members,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rest_shapes() {
|
||||
let raw = vec![
|
||||
json!({"id": 1, "type": "domain", "name": "b4l.co.th"}),
|
||||
json!({"id": 2, "type": "individual", "name": "alice", "emails": ["alice@b4l.co.th", "al@b4l.co.th"],
|
||||
"secrets": ["$6$x$y"], "quota": 1073741824u64, "description": "Alice"}),
|
||||
// wrapped value forms seen in 0.15 dumps
|
||||
json!({"id": 3, "type": "individual", "name": {"string": "bob"}, "emails": {"stringList": ["bob@b4l.co.th"]},
|
||||
"secrets": {"string": "plain"}, "quota": {"integer": 0}}),
|
||||
json!({"id": 4, "type": "list", "name": "team", "emails": "team@b4l.co.th", "members": ["alice", 3],
|
||||
"externalMembers": ["x@gmail.com"]}),
|
||||
json!({"id": 5, "type": "role", "name": "admin"}),
|
||||
];
|
||||
let d = rest_to_directory(&raw);
|
||||
assert_eq!(d.domains, vec!["b4l.co.th"]);
|
||||
assert_eq!(d.principals.len(), 3);
|
||||
assert_eq!(d.principals[0].emails, vec!["alice@b4l.co.th", "al@b4l.co.th"]);
|
||||
assert_eq!(d.principals[0].quota, Some(1073741824));
|
||||
assert_eq!(d.principals[1].name, "bob");
|
||||
assert_eq!(d.principals[1].emails, vec!["bob@b4l.co.th"]);
|
||||
assert_eq!(d.principals[1].secrets, vec!["plain"]);
|
||||
assert_eq!(d.principals[2].members, vec!["alice", "bob", "x@gmail.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jmap_shapes() {
|
||||
let domains = vec![json!({"id": "d1", "name": "b4l.co.th"}), json!({"id": "d2", "name": "Poyrecords.com"})];
|
||||
let accounts = vec![
|
||||
json!({"id": "a1", "@type": "User", "name": "alice", "domainId": "d1", "emailAddress": "alice@b4l.co.th",
|
||||
"aliases": {"0": {"name": "a", "domainId": "d2", "enabled": true}, "1": {"name": "off", "domainId": "d1", "enabled": false}},
|
||||
"credentials": {"0": {"@type": "Password", "secret": "$6$s$h"}, "1": {"@type": "AppPassword", "description": "phone"}},
|
||||
"quotas": {"maxDiskQuota": 5}}),
|
||||
json!({"id": "a2", "@type": "Group", "name": "office", "domainId": "d1", "credentials": {}}),
|
||||
];
|
||||
let lists =
|
||||
vec![json!({"id": "l1", "name": "team", "domainId": "d1", "recipients": {"alice@b4l.co.th": true}})];
|
||||
let d = jmap_to_directory(&domains, &accounts, &lists);
|
||||
assert_eq!(d.domains, vec!["b4l.co.th", "poyrecords.com"]);
|
||||
let a = &d.principals[0];
|
||||
assert_eq!(a.emails, vec!["alice@b4l.co.th", "a@poyrecords.com"]);
|
||||
assert_eq!(a.secrets, vec!["$6$s$h", "$app$(redacted)"]);
|
||||
assert_eq!(a.quota, Some(5));
|
||||
assert_eq!(d.principals[1].kind, PrincipalKind::Group);
|
||||
assert_eq!(d.principals[1].name, "office@b4l.co.th");
|
||||
assert_eq!(d.principals[2].kind, PrincipalKind::List);
|
||||
assert_eq!(d.principals[2].members, vec!["alice@b4l.co.th"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaping() {
|
||||
assert_eq!(url_escape("alice@b4l.co.th"), "alice%40b4l.co.th");
|
||||
}
|
||||
}
|
||||
+660
@@ -0,0 +1,660 @@
|
||||
//! IMAP → IMAP copy: folder mapping, de-duplication, incremental sync.
|
||||
//!
|
||||
//! Stateless by design: every run compares source and destination folder
|
||||
//! contents (Message-ID, or a digest of Date/From/Subject when there is none)
|
||||
//! and copies only what is missing, so it can be re-run any number of times
|
||||
//! (bulk copy before the DNS switch, delta passes after it).
|
||||
|
||||
use crate::config::{Auth, Config, Endpoint, FolderCfg};
|
||||
use crate::imap::client::ImapNo;
|
||||
use crate::imap::{Client, Login, Mailbox, MsgMeta, Namespaces, SelectInfo, utf7};
|
||||
use crate::plan::{Account, Secrets};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logging in
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PasswordBook {
|
||||
map: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl PasswordBook {
|
||||
/// `login:password` per line; `#` comments and blank lines ignored.
|
||||
pub fn load_file(&mut self, path: &Path) -> Result<()> {
|
||||
let s = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
for (i, line) in s.lines().enumerate() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
if line.trim().is_empty() || line.trim_start().starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let (l, p) =
|
||||
line.split_once(':').ok_or_else(|| anyhow!("{}:{}: expected login:password", path.display(), i + 1))?;
|
||||
self.map.insert(l.trim().to_ascii_lowercase(), p.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn get(&self, login: &str) -> Option<&str> {
|
||||
self.map.get(&login.to_ascii_lowercase()).map(|s| s.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// One side of the migration with its resolved credentials.
|
||||
pub struct Side {
|
||||
pub name: &'static str,
|
||||
pub ep: Endpoint,
|
||||
master_password: Option<String>,
|
||||
book: PasswordBook,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub fn new(name: &'static str, ep: &Endpoint, secrets: &Secrets) -> Result<Side> {
|
||||
let mut book = PasswordBook::default();
|
||||
let master_password = match &ep.auth {
|
||||
Auth::Password { passwords_file } => {
|
||||
if let Some(f) = passwords_file {
|
||||
book.load_file(f)?;
|
||||
}
|
||||
// Plaintext passwords found on the source work on both sides:
|
||||
// the destination hash was derived from them.
|
||||
for (login, s) in &secrets.accounts {
|
||||
if let Some(p) = &s.plaintext {
|
||||
book.map.entry(login.to_ascii_lowercase()).or_insert_with(|| p.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
auth => Some(auth.master_secret().unwrap().resolve(&format!("[{name}.auth] master password"))?),
|
||||
};
|
||||
Ok(Side { name, ep: ep.clone(), master_password, book })
|
||||
}
|
||||
|
||||
/// Connect and log in as `login` (falling back to `alt` for password lookup).
|
||||
pub async fn open(&self, login: &str, alt: &str) -> Result<Client> {
|
||||
let label = format!("{} {}", self.name, login);
|
||||
let mut c = Client::connect(&self.ep, &label).await?;
|
||||
match &self.ep.auth {
|
||||
Auth::Master { user, login_format, .. } => {
|
||||
let composite = login_format.replace("{user}", login).replace("{master}", user);
|
||||
c.login(Login::Plain { user: &composite, password: self.master_password.as_deref().unwrap() }).await?;
|
||||
}
|
||||
Auth::SaslAuthzid { user, .. } => {
|
||||
c.login(Login::Authzid {
|
||||
authzid: login,
|
||||
authcid: user,
|
||||
password: self.master_password.as_deref().unwrap(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
Auth::Password { .. } => {
|
||||
let pw = self
|
||||
.book
|
||||
.get(login)
|
||||
.or_else(|| self.book.get(alt))
|
||||
.ok_or_else(|| anyhow!("{label}: no password known (add it to passwords_file)"))?;
|
||||
c.login(Login::Plain { user: login, password: pw }).await?;
|
||||
}
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Folder mapping
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FolderMap {
|
||||
pub src_wire: String,
|
||||
pub src_name: String,
|
||||
pub dst_name: String,
|
||||
pub dst_wire: String,
|
||||
pub why: &'static str,
|
||||
}
|
||||
|
||||
const SPECIAL_NAMES: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"\\Sent",
|
||||
&["sent", "sent items", "sent messages", "sent mail", "gesendet", "gesendete elemente", "gesendete objekte"],
|
||||
),
|
||||
("\\Trash", &["trash", "deleted items", "deleted messages", "bin", "papierkorb", "gelöschte elemente"]),
|
||||
("\\Junk", &["junk", "junk mail", "junk e-mail", "junk email", "spam", "spamverdacht"]),
|
||||
("\\Drafts", &["drafts", "draft", "entwürfe"]),
|
||||
("\\Archive", &["archive", "archives", "archiv"]),
|
||||
];
|
||||
|
||||
fn special_of(m: &Mailbox, delim: char) -> Option<&'static str> {
|
||||
if let Some(s) = m.special_use()
|
||||
&& SPECIAL_NAMES.iter().any(|(a, _)| *a == s)
|
||||
{
|
||||
return Some(s);
|
||||
}
|
||||
if m.name.contains(delim) {
|
||||
return None;
|
||||
}
|
||||
let lower = m.name.to_lowercase();
|
||||
SPECIAL_NAMES.iter().find(|(_, names)| names.contains(&lower.as_str())).map(|(a, _)| *a)
|
||||
}
|
||||
|
||||
fn excluded(name: &str, delim: char, patterns: &[String]) -> bool {
|
||||
patterns.iter().any(|p| {
|
||||
if let Some(prefix) = p.strip_suffix("/*").or_else(|| p.strip_suffix(&format!("{delim}*"))) {
|
||||
name == prefix || name.starts_with(&format!("{prefix}{delim}"))
|
||||
} else {
|
||||
name == p
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn map_folders(
|
||||
src: &[Mailbox],
|
||||
src_ns: Option<&Namespaces>,
|
||||
dst: &[Mailbox],
|
||||
dst_delim: char,
|
||||
cfg: &FolderCfg,
|
||||
) -> (Vec<FolderMap>, Vec<(String, String)>) {
|
||||
let mut maps = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
let ns_delim = src_ns.and_then(|n| n.personal.first()).and_then(|(_, d)| *d);
|
||||
for m in src {
|
||||
let sd = m.delim.or(ns_delim).unwrap_or('/');
|
||||
if !m.selectable() {
|
||||
skipped.push((m.name.clone(), "not selectable (parent only)".into()));
|
||||
continue;
|
||||
}
|
||||
if let Some(ns) = src_ns
|
||||
&& ns.foreign.iter().any(|p| !p.is_empty() && m.wire.starts_with(p.as_str()))
|
||||
{
|
||||
skipped.push((m.name.clone(), "shared / other users' namespace".into()));
|
||||
continue;
|
||||
}
|
||||
if excluded(&m.name, sd, &cfg.exclude) {
|
||||
skipped.push((m.name.clone(), "excluded by config".into()));
|
||||
continue;
|
||||
}
|
||||
let (dst_name, why) = if let Some(t) = cfg.map.get(&m.name) {
|
||||
(t.clone(), "folders.map")
|
||||
} else if m.name.eq_ignore_ascii_case("INBOX") {
|
||||
("INBOX".to_string(), "inbox")
|
||||
} else if let Some(sp) = special_of(m, sd) {
|
||||
match dst.iter().find(|d| d.has_attr(sp)) {
|
||||
Some(d) => (d.name.clone(), "special-use (existing)"),
|
||||
None => (cfg.special.get(sp).cloned().unwrap_or_else(|| m.name.clone()), "special-use (default name)"),
|
||||
}
|
||||
} else {
|
||||
let parts: Vec<String> = m
|
||||
.name
|
||||
.split(sd)
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
if i == 0 && p.eq_ignore_ascii_case("INBOX") {
|
||||
"INBOX".to_string()
|
||||
} else {
|
||||
p.replace(dst_delim, &cfg.separator_replacement)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(parts.join(&dst_delim.to_string()), "same path")
|
||||
};
|
||||
let dst_wire = if dst_name.eq_ignore_ascii_case("INBOX") { "INBOX".into() } else { utf7::encode(&dst_name) };
|
||||
maps.push(FolderMap { src_wire: m.wire.clone(), src_name: m.name.clone(), dst_name, dst_wire, why });
|
||||
}
|
||||
(maps, skipped)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message identity
|
||||
|
||||
fn collapse_ws(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Identity of a message for de-duplication across servers.
|
||||
pub fn message_key(headers: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(headers);
|
||||
let mut fields: Vec<(String, String)> = Vec::new();
|
||||
for line in text.split('\n') {
|
||||
let line = line.trim_end_matches('\r');
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
if let Some(last) = fields.last_mut() {
|
||||
last.1.push(' ');
|
||||
last.1.push_str(line.trim());
|
||||
}
|
||||
} else if let Some((k, v)) = line.split_once(':') {
|
||||
fields.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
|
||||
}
|
||||
}
|
||||
let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| collapse_ws(v)).unwrap_or_default();
|
||||
let mid = get("message-id");
|
||||
if !mid.is_empty() {
|
||||
return format!("mid:{mid}");
|
||||
}
|
||||
let digest = Sha256::digest(format!("{}\n{}\n{}", get("date"), get("from"), get("subject")).as_bytes());
|
||||
format!("hdr:{}", hex::encode(&digest[..12]))
|
||||
}
|
||||
|
||||
fn has_flag(flags: &[String], f: &str) -> bool {
|
||||
flags.iter().any(|x| x.eq_ignore_ascii_case(f))
|
||||
}
|
||||
|
||||
/// Flags to set on the destination: drop \Recent and what it won't store.
|
||||
pub fn dest_flags(src: &[String], dst: &SelectInfo) -> Vec<String> {
|
||||
src.iter().filter(|f| !f.eq_ignore_ascii_case("\\Recent") && dst.accepts_flag(f)).cloned().collect()
|
||||
}
|
||||
|
||||
fn flag_set(flags: &[String]) -> Vec<String> {
|
||||
let mut v: Vec<String> =
|
||||
flags.iter().filter(|f| !f.eq_ignore_ascii_case("\\Recent")).map(|f| f.to_ascii_lowercase()).collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
pub struct Pairing<'a> {
|
||||
pub missing: Vec<&'a MsgMeta>,
|
||||
pub matched: Vec<(&'a MsgMeta, &'a MsgMeta)>,
|
||||
}
|
||||
|
||||
/// Multiset match of source against destination messages by key.
|
||||
pub fn pair_messages<'a>(src: &'a [MsgMeta], dst: &'a [MsgMeta]) -> Pairing<'a> {
|
||||
let mut by_key: HashMap<String, VecDeque<&MsgMeta>> = HashMap::new();
|
||||
for d in dst {
|
||||
by_key.entry(message_key(&d.headers)).or_default().push_back(d);
|
||||
}
|
||||
let mut p = Pairing { missing: Vec::new(), matched: Vec::new() };
|
||||
for s in src {
|
||||
match by_key.get_mut(&message_key(&s.headers)).and_then(|q| q.pop_front()) {
|
||||
Some(d) => p.matched.push((s, d)),
|
||||
None => p.missing.push(s),
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SyncOpts {
|
||||
pub dry_run: bool,
|
||||
pub sync_flags: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct FolderReport {
|
||||
pub src: String,
|
||||
pub dst: String,
|
||||
pub why: String,
|
||||
pub src_messages: usize,
|
||||
pub dst_messages_before: usize,
|
||||
/// Present on the source, absent on the destination (before copying).
|
||||
pub missing: usize,
|
||||
pub copied: usize,
|
||||
pub copied_bytes: u64,
|
||||
pub failed: usize,
|
||||
pub skipped_deleted: usize,
|
||||
pub flags_updated: usize,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct AccountReport {
|
||||
pub login: String,
|
||||
pub source_login: String,
|
||||
pub ok: bool,
|
||||
pub error: Option<String>,
|
||||
pub attempts: u32,
|
||||
pub folders: Vec<FolderReport>,
|
||||
pub skipped_folders: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl AccountReport {
|
||||
pub fn total(&self, f: impl Fn(&FolderReport) -> usize) -> usize {
|
||||
self.folders.iter().map(f).sum()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sync_account(
|
||||
cfg: &Config,
|
||||
src: &Side,
|
||||
dst: &Side,
|
||||
acct: &Account,
|
||||
opts: SyncOpts,
|
||||
) -> Result<AccountReport> {
|
||||
let mut rep =
|
||||
AccountReport { login: acct.login.clone(), source_login: acct.source_login.clone(), ..Default::default() };
|
||||
let mut s = src.open(&acct.source_login, &acct.login).await?;
|
||||
let mut d = dst.open(&acct.login, &acct.source_login).await?;
|
||||
|
||||
let src_ns = s.namespace().await?;
|
||||
let src_boxes = s.list().await?;
|
||||
let dst_ns = d.namespace().await?;
|
||||
let mut dst_boxes = d.list().await?;
|
||||
let dst_delim = dst_ns
|
||||
.as_ref()
|
||||
.and_then(|n| n.personal.first())
|
||||
.and_then(|(_, dl)| *dl)
|
||||
.or_else(|| dst_boxes.iter().find_map(|m| m.delim))
|
||||
.unwrap_or('.');
|
||||
|
||||
let (maps, skipped) = map_folders(&src_boxes, src_ns.as_ref(), &dst_boxes, dst_delim, &cfg.folders);
|
||||
rep.skipped_folders = skipped;
|
||||
|
||||
for fm in maps {
|
||||
let mut fr = FolderReport {
|
||||
src: fm.src_name.clone(),
|
||||
dst: fm.dst_name.clone(),
|
||||
why: fm.why.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let sinfo = s.select(&fm.src_wire, true).await.with_context(|| format!("source folder {}", fm.src_name))?;
|
||||
let src_meta = if sinfo.exists > 0 { s.fetch_meta().await? } else { Vec::new() };
|
||||
fr.src_messages = src_meta.len();
|
||||
|
||||
let exists = dst_boxes.iter().any(|b| b.wire == fm.dst_wire);
|
||||
if !exists && !opts.dry_run {
|
||||
d.create(&fm.dst_wire).await.with_context(|| format!("creating {}", fm.dst_name))?;
|
||||
if cfg.folders.subscribe
|
||||
&& let Err(e) = d.subscribe(&fm.dst_wire).await
|
||||
{
|
||||
tracing::warn!("{}: subscribe {} failed: {e}", acct.login, fm.dst_name);
|
||||
}
|
||||
dst_boxes.push(Mailbox {
|
||||
wire: fm.dst_wire.clone(),
|
||||
name: fm.dst_name.clone(),
|
||||
delim: Some(dst_delim),
|
||||
attrs: vec![],
|
||||
});
|
||||
}
|
||||
let (dinfo, dst_meta) = if exists || !opts.dry_run {
|
||||
let readonly = opts.dry_run || !opts.sync_flags;
|
||||
let dinfo = d
|
||||
.select(&fm.dst_wire, readonly)
|
||||
.await
|
||||
.with_context(|| format!("destination folder {}", fm.dst_name))?;
|
||||
let meta = if dinfo.exists > 0 { d.fetch_meta().await? } else { Vec::new() };
|
||||
(dinfo, meta)
|
||||
} else {
|
||||
(SelectInfo::default(), Vec::new())
|
||||
};
|
||||
fr.dst_messages_before = dst_meta.len();
|
||||
|
||||
let pairing = pair_messages(&src_meta, &dst_meta);
|
||||
let mut to_copy: Vec<&MsgMeta> = Vec::new();
|
||||
for m in &pairing.missing {
|
||||
if cfg.sync.skip_deleted && has_flag(&m.flags, "\\Deleted") {
|
||||
fr.skipped_deleted += 1;
|
||||
} else {
|
||||
to_copy.push(m);
|
||||
}
|
||||
}
|
||||
fr.missing = to_copy.len();
|
||||
|
||||
if !opts.dry_run {
|
||||
// Batches bounded by message count and bytes.
|
||||
let mut batch: Vec<u32> = Vec::new();
|
||||
let mut batch_bytes = 0u64;
|
||||
let mut batches: Vec<Vec<u32>> = Vec::new();
|
||||
for m in &to_copy {
|
||||
if !batch.is_empty()
|
||||
&& (batch.len() >= cfg.sync.batch_messages || batch_bytes + m.size > cfg.sync.batch_bytes)
|
||||
{
|
||||
batches.push(std::mem::take(&mut batch));
|
||||
batch_bytes = 0;
|
||||
}
|
||||
batch.push(m.uid);
|
||||
batch_bytes += m.size;
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
batches.push(batch);
|
||||
}
|
||||
for uids in batches {
|
||||
let fetched = s.fetch_bodies(&uids).await?;
|
||||
if fetched.len() != uids.len() {
|
||||
let got: Vec<u32> = fetched.iter().map(|f| f.uid).collect();
|
||||
for u in uids.iter().filter(|u| !got.contains(u)) {
|
||||
fr.failed += 1;
|
||||
push_err(&mut fr, format!("uid {u}: vanished from source before it could be copied"));
|
||||
}
|
||||
}
|
||||
for f in fetched {
|
||||
let flags = dest_flags(&f.flags, &dinfo);
|
||||
match d.append(&fm.dst_wire, &flags, f.internaldate.as_deref(), &f.body).await {
|
||||
Ok(()) => {
|
||||
fr.copied += 1;
|
||||
fr.copied_bytes += f.body.len() as u64;
|
||||
}
|
||||
Err(e) if e.downcast_ref::<ImapNo>().is_some() => {
|
||||
fr.failed += 1;
|
||||
push_err(&mut fr, format!("uid {} ({} bytes): {e}", f.uid, f.body.len()));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
if opts.sync_flags {
|
||||
for (sm, dm) in &pairing.matched {
|
||||
if flag_set(&dest_flags(&sm.flags, &dinfo)) != flag_set(&dest_flags(&dm.flags, &dinfo)) {
|
||||
d.uid_store_flags(dm.uid, &dest_flags(&sm.flags, &dinfo)).await?;
|
||||
fr.flags_updated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"{}: {} -> {}: {} on source, {} already there, {} {}{}",
|
||||
acct.login,
|
||||
fr.src,
|
||||
fr.dst,
|
||||
fr.src_messages,
|
||||
fr.dst_messages_before,
|
||||
if opts.dry_run { fr.missing } else { fr.copied },
|
||||
if opts.dry_run { "missing" } else { "copied" },
|
||||
if fr.failed > 0 { format!(", {} FAILED", fr.failed) } else { String::new() }
|
||||
);
|
||||
rep.folders.push(fr);
|
||||
}
|
||||
s.logout().await;
|
||||
d.logout().await;
|
||||
rep.ok = rep.folders.iter().all(|f| f.failed == 0);
|
||||
Ok(rep)
|
||||
}
|
||||
|
||||
fn push_err(fr: &mut FolderReport, e: String) {
|
||||
tracing::warn!("{}: {e}", fr.src);
|
||||
if fr.errors.len() < 20 {
|
||||
fr.errors.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_sync(
|
||||
cfg: Arc<Config>,
|
||||
src: Arc<Side>,
|
||||
dst: Arc<Side>,
|
||||
accounts: Vec<Account>,
|
||||
opts: SyncOpts,
|
||||
) -> Vec<AccountReport> {
|
||||
let sem = Arc::new(tokio::sync::Semaphore::new(cfg.sync.jobs.max(1)));
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for (idx, acct) in accounts.into_iter().enumerate() {
|
||||
let (cfg, src, dst, sem) = (cfg.clone(), src.clone(), dst.clone(), sem.clone());
|
||||
set.spawn(async move {
|
||||
let _permit = sem.acquire_owned().await.unwrap();
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
match sync_account(&cfg, &src, &dst, &acct, opts).await {
|
||||
Ok(mut r) => {
|
||||
r.attempts = attempt;
|
||||
return (idx, r);
|
||||
}
|
||||
Err(e) if attempt <= cfg.sync.retries => {
|
||||
tracing::warn!("{}: attempt {attempt} failed: {e:#}; retrying", acct.login);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5 * attempt as u64)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("{}: {e:#}", acct.login);
|
||||
return (
|
||||
idx,
|
||||
AccountReport {
|
||||
login: acct.login.clone(),
|
||||
source_login: acct.source_login.clone(),
|
||||
ok: false,
|
||||
error: Some(format!("{e:#}")),
|
||||
attempts: attempt,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
while let Some(r) = set.join_next().await {
|
||||
match r {
|
||||
Ok(x) => out.push(x),
|
||||
Err(e) => tracing::error!("task panicked: {e}"),
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|(i, _)| *i);
|
||||
out.into_iter().map(|(_, r)| r).collect()
|
||||
}
|
||||
|
||||
/// Log in on both sides and list folders, without touching any mail.
|
||||
pub async fn check_account(src: &Side, dst: &Side, acct: &Account) -> Result<(usize, usize)> {
|
||||
let mut s = src.open(&acct.source_login, &acct.login).await?;
|
||||
let ns = s.list().await?.len();
|
||||
s.logout().await;
|
||||
let mut d = dst.open(&acct.login, &acct.source_login).await?;
|
||||
let nd = d.list().await?.len();
|
||||
d.logout().await;
|
||||
Ok((ns, nd))
|
||||
}
|
||||
|
||||
pub fn ensure_accounts(sel: Vec<Account>) -> Result<Vec<Account>> {
|
||||
if sel.is_empty() {
|
||||
bail!("no accounts selected");
|
||||
}
|
||||
Ok(sel)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mb(name: &str, delim: char, attrs: &[&str]) -> Mailbox {
|
||||
Mailbox {
|
||||
wire: utf7::encode(name),
|
||||
name: name.into(),
|
||||
delim: Some(delim),
|
||||
attrs: attrs.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_mapping_stalwart_to_dovecot() {
|
||||
let src = vec![
|
||||
mb("INBOX", '/', &[]),
|
||||
mb("Sent Items", '/', &["\\Sent"]),
|
||||
mb("Deleted Items", '/', &["\\Trash"]),
|
||||
mb("Junk Mail", '/', &[]),
|
||||
mb("Projects", '/', &["\\HasChildren"]),
|
||||
mb("Projects/2024", '/', &[]),
|
||||
mb("Projects/v1.2", '/', &[]),
|
||||
mb("Café", '/', &[]),
|
||||
mb("Shared Folders", '/', &["\\Noselect"]),
|
||||
mb("Old/Stuff", '/', &[]),
|
||||
mb("Archive", '/', &["\\Archive"]),
|
||||
];
|
||||
let dst = vec![mb("INBOX", '.', &[]), mb("Sent", '.', &["\\Sent"]), mb("Drafts", '.', &["\\Drafts"])];
|
||||
let cfg = FolderCfg { exclude: vec!["Old/*".into()], ..Default::default() };
|
||||
let (maps, skipped) = map_folders(&src, None, &dst, '.', &cfg);
|
||||
let m: HashMap<_, _> = maps.iter().map(|f| (f.src_name.as_str(), f.dst_name.as_str())).collect();
|
||||
assert_eq!(m["INBOX"], "INBOX");
|
||||
assert_eq!(m["Sent Items"], "Sent");
|
||||
assert_eq!(m["Deleted Items"], "Trash");
|
||||
assert_eq!(m["Junk Mail"], "Junk");
|
||||
assert_eq!(m["Projects"], "Projects");
|
||||
assert_eq!(m["Projects/2024"], "Projects.2024");
|
||||
assert_eq!(m["Projects/v1.2"], "Projects.v1_2");
|
||||
assert_eq!(m["Café"], "Café");
|
||||
assert_eq!(m["Archive"], "Archive");
|
||||
let cafe = maps.iter().find(|f| f.src_name == "Café").unwrap();
|
||||
assert_eq!(cafe.dst_wire, "Caf&AOk-");
|
||||
assert!(skipped.iter().any(|(n, _)| n == "Shared Folders"));
|
||||
assert!(skipped.iter().any(|(n, r)| n == "Old/Stuff" && r.contains("excluded")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_namespace_skipped() {
|
||||
let src = vec![mb("INBOX", '/', &[]), mb("Shared Folders/team/INBOX", '/', &[])];
|
||||
let ns = Namespaces { personal: vec![("".into(), Some('/'))], foreign: vec!["Shared Folders/".into()] };
|
||||
let (maps, skipped) = map_folders(&src, Some(&ns), &[], '.', &FolderCfg::default());
|
||||
assert_eq!(maps.len(), 1);
|
||||
assert_eq!(skipped.len(), 1);
|
||||
}
|
||||
|
||||
fn meta(uid: u32, hdr: &str, flags: &[&str]) -> MsgMeta {
|
||||
MsgMeta {
|
||||
uid,
|
||||
flags: flags.iter().map(|s| s.to_string()).collect(),
|
||||
internaldate: None,
|
||||
size: 10,
|
||||
headers: hdr.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_and_pairing() {
|
||||
assert_eq!(message_key(b"Message-ID:\r\n <abc@x>\r\n\r\n"), "mid:<abc@x>");
|
||||
assert_eq!(message_key(b"message-id: <abc@x>\r\n"), "mid:<abc@x>");
|
||||
let k1 = message_key(b"Date: Mon, 1 Jan 2024\r\nSubject: hi\r\n");
|
||||
let k2 = message_key(b"Subject: hi\r\nDate: Mon, 1 Jan 2024\r\n");
|
||||
assert_eq!(k1, k2);
|
||||
assert!(k1.starts_with("hdr:"));
|
||||
|
||||
let src = vec![
|
||||
meta(1, "Message-ID: <a>\r\n", &["\\Seen"]),
|
||||
meta(2, "Message-ID: <a>\r\n", &[]),
|
||||
meta(3, "Message-ID: <b>\r\n", &[]),
|
||||
meta(4, "Subject: no id\r\n", &[]),
|
||||
];
|
||||
let dst = vec![meta(7, "Message-ID: <a>\r\n", &[]), meta(8, "Subject: no id\r\n", &[])];
|
||||
let p = pair_messages(&src, &dst);
|
||||
let missing: Vec<u32> = p.missing.iter().map(|m| m.uid).collect();
|
||||
assert_eq!(missing, vec![2, 3]);
|
||||
assert_eq!(p.matched.len(), 2);
|
||||
assert_eq!(p.matched[0].0.uid, 1);
|
||||
assert_eq!(p.matched[0].1.uid, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_filtered() {
|
||||
let info = SelectInfo {
|
||||
permanent_flags: Some(vec!["\\Seen".into(), "\\Flagged".into(), "\\*".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let f = dest_flags(&["\\Recent".into(), "\\Seen".into(), "$Forwarded".into(), "\\Answered".into()], &info);
|
||||
assert_eq!(f, vec!["\\Seen", "$Forwarded"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_book() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f = dir.path().join("pw.txt");
|
||||
std::fs::write(&f, "# comment\nAlice@x.org:pa:ss word\r\n\nbob:b\n").unwrap();
|
||||
let mut b = PasswordBook::default();
|
||||
b.load_file(&f).unwrap();
|
||||
assert_eq!(b.get("alice@x.org"), Some("pa:ss word"));
|
||||
assert_eq!(b.get("BOB"), Some("b"));
|
||||
}
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
# End-to-end test: Stalwart 0.15 (old) -> Simple NixOS Mailserver (new),
|
||||
# driven by mailmig on a third machine. Exercises: REST inventory, password
|
||||
# hash carry-over (sha512-crypt, bcrypt, plaintext, none), the generated Nix
|
||||
# (imported by the SNM node), Stalwart master-user login over STARTTLS,
|
||||
# Dovecot master-user login over implicit TLS, folder mapping, flags/dates,
|
||||
# de-duplication, idempotence, delta and flag passes, and DKIM -> zone output.
|
||||
{ mailmig, nixos-mailserver }:
|
||||
{ pkgs, lib, ... }:
|
||||
let
|
||||
certs = pkgs.runCommand "mailmig-test-certs" { nativeBuildInputs = [ pkgs.openssl ]; } ''
|
||||
mkdir $out && cd $out
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
|
||||
-keyout ca.key -out ca.crt -days 3650 -subj "/CN=mailmig test CA"
|
||||
for h in old new; do
|
||||
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
|
||||
-keyout $h.key -out $h.csr -subj "/CN=$h.test"
|
||||
printf 'subjectAltName=DNS:%s.test\nextendedKeyUsage=serverAuth\nkeyUsage=digitalSignature\nbasicConstraints=CA:FALSE\n' $h > $h.ext
|
||||
openssl x509 -req -in $h.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
|
||||
-out $h.crt -days 3650 -extfile $h.ext
|
||||
done
|
||||
chmod 644 *
|
||||
'';
|
||||
|
||||
masterUsers = pkgs.runCommand "dovecot-master-users" { nativeBuildInputs = [ pkgs.mkpasswd ]; } ''
|
||||
printf 'mailmig:{CRYPT}%s\n' "$(mkpasswd -m sha-512 masterpass)" > $out
|
||||
'';
|
||||
|
||||
migrated = import ./expected-mail-accounts.nix;
|
||||
hashFile = login: "/var/lib/mailmig-hashes/${login}.hash";
|
||||
|
||||
hosts =
|
||||
nodes:
|
||||
{
|
||||
${nodes.old.networking.primaryIPAddress} = [ "old.test" ];
|
||||
${nodes.new.networking.primaryIPAddress} = [ "new.test" ];
|
||||
};
|
||||
|
||||
mailmigToml = pkgs.writeText "mailmig.toml" ''
|
||||
[source]
|
||||
host = "old.test"
|
||||
port = 143
|
||||
tls = "starttls"
|
||||
[source.auth]
|
||||
mode = "master"
|
||||
user = "migrator"
|
||||
password = "migratorpass"
|
||||
login_format = "{user}%{master}"
|
||||
|
||||
[stalwart]
|
||||
url = "http://old.test:8080"
|
||||
user = "admin"
|
||||
password = "adminpass"
|
||||
|
||||
[destination]
|
||||
host = "new.test"
|
||||
tls = "implicit"
|
||||
ca_file = "${certs}/ca.crt"
|
||||
[destination.auth]
|
||||
mode = "master"
|
||||
user = "mailmig"
|
||||
password_env = "DST_MASTER"
|
||||
login_format = "{user}*{master}"
|
||||
|
||||
[folders]
|
||||
exclude = ["Shared Folders/*"]
|
||||
|
||||
[sync]
|
||||
jobs = 2
|
||||
batch_messages = 3
|
||||
|
||||
[dns]
|
||||
provider = "zonefile"
|
||||
mx = ["10 new.test"]
|
||||
dmarc = "v=DMARC1; p=quarantine"
|
||||
old_hosts = ["old.test"]
|
||||
client_host = "new.test"
|
||||
'';
|
||||
in
|
||||
{
|
||||
name = "mailmig-e2e";
|
||||
|
||||
nodes.old =
|
||||
{ nodes, ... }:
|
||||
{
|
||||
virtualisation.memorySize = 1024;
|
||||
networking.hosts = hosts nodes;
|
||||
services.stalwart = {
|
||||
enable = true;
|
||||
stateVersion = "26.05";
|
||||
openFirewall = true;
|
||||
settings = {
|
||||
server.hostname = "old.test";
|
||||
certificate.test = {
|
||||
cert = "%{file:${certs}/old.crt}%";
|
||||
private-key = "%{file:${certs}/old.key}%";
|
||||
};
|
||||
server.tls = {
|
||||
certificate = "test";
|
||||
enable = true;
|
||||
implicit = false;
|
||||
};
|
||||
server.listener = {
|
||||
imap = {
|
||||
bind = [ "[::]:143" ];
|
||||
protocol = "imap";
|
||||
};
|
||||
http = {
|
||||
bind = [ "[::]:8080" ];
|
||||
protocol = "http";
|
||||
};
|
||||
};
|
||||
authentication.fallback-admin = {
|
||||
user = "admin";
|
||||
secret = "adminpass";
|
||||
};
|
||||
# Stalwart 0.15 master user: "<account>%migrator" + this secret.
|
||||
authentication.master = {
|
||||
user = "migrator";
|
||||
secret = "migratorpass";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
nodes.new =
|
||||
{ nodes, ... }:
|
||||
{
|
||||
imports = [
|
||||
nixos-mailserver.nixosModules.default
|
||||
./expected-dovecot-master-user.nix
|
||||
];
|
||||
virtualisation.memorySize = 1536;
|
||||
virtualisation.cores = 2;
|
||||
networking.hosts = hosts nodes;
|
||||
mailserver = {
|
||||
enable = true;
|
||||
stateVersion = 3;
|
||||
fqdn = "new.test";
|
||||
inherit (migrated) domains forwards;
|
||||
localDnsResolver = false;
|
||||
x509 = {
|
||||
certificateFile = "${certs}/new.crt";
|
||||
privateKeyFile = "${certs}/new.key";
|
||||
};
|
||||
# Same mapping the clan module uses, with plain files instead of clan vars.
|
||||
accounts = lib.mapAttrs (login: a: {
|
||||
inherit (a) aliases;
|
||||
quota = a.quota or null;
|
||||
hashedPasswordFile = hashFile login;
|
||||
}) migrated.accounts;
|
||||
};
|
||||
# Placeholder hashes ("!" never verifies) until mailmig's are copied in.
|
||||
systemd.tmpfiles.settings."10-mailmig" =
|
||||
lib.mapAttrs' (login: _: lib.nameValuePair (hashFile login) { f.argument = "!"; }) migrated.accounts
|
||||
// {
|
||||
"/var/lib/dovecot-master-users"."L+".argument = "${masterUsers}";
|
||||
};
|
||||
services.rspamd.locals."options.inc".text = ''
|
||||
dns {
|
||||
nameservers = ["127.0.0.1"];
|
||||
timeout = 0.0s;
|
||||
retransmits = 0;
|
||||
}
|
||||
'';
|
||||
services.rspamd.overrides."options.inc".enable = false;
|
||||
services.postfix-tlspol.configurePostfix = lib.mkForce false;
|
||||
};
|
||||
|
||||
nodes.client =
|
||||
{ nodes, ... }:
|
||||
{
|
||||
networking.hosts = hosts nodes;
|
||||
security.pki.certificateFiles = [ "${certs}/ca.crt" ];
|
||||
environment.systemPackages = [
|
||||
mailmig
|
||||
pkgs.python3
|
||||
pkgs.mkpasswd
|
||||
pkgs.jq
|
||||
pkgs.diffutils
|
||||
];
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import json
|
||||
|
||||
def mig(args, ok=True):
|
||||
cmd = f"cd /root/mig && DST_MASTER=masterpass mailmig {args} 2>&1"
|
||||
return client.succeed(cmd) if ok else client.fail(cmd)
|
||||
|
||||
def fixture(args):
|
||||
return client.succeed(f"python3 ${./imap_fixture.py} {args}")
|
||||
|
||||
def report(path):
|
||||
return json.loads(client.succeed(f"cat /root/mig/{path}"))
|
||||
|
||||
def total(rep, key):
|
||||
return sum(f[key] for a in rep for f in a["folders"])
|
||||
|
||||
start_all()
|
||||
old.wait_for_unit("stalwart.service")
|
||||
old.wait_for_open_port(8080)
|
||||
old.wait_for_open_port(143)
|
||||
new.wait_for_unit("dovecot.service")
|
||||
new.wait_for_open_port(993)
|
||||
client.wait_for_unit("multi-user.target")
|
||||
|
||||
with subtest("provision and seed Stalwart"):
|
||||
alice = client.succeed("mkpasswd -m sha-512 alicepass").strip()
|
||||
carol = client.succeed("mkpasswd -m bcrypt carolpass").strip()
|
||||
client.succeed(f"python3 ${./provision.py} '{alice}' '{carol}'")
|
||||
fixture("seed")
|
||||
|
||||
with subtest("1. inventory"):
|
||||
client.succeed("mkdir -p /root/mig && cp ${mailmigToml} /root/mig/mailmig.toml")
|
||||
print(mig("inventory --raw raw.json"))
|
||||
plan = client.succeed("cat /root/mig/plan.toml")
|
||||
print(plan)
|
||||
secrets = json.loads(client.succeed("cat /root/mig/secrets.json"))["accounts"]
|
||||
assert secrets["alice@b4l.test"]["hash"] == alice, secrets["alice@b4l.test"]
|
||||
assert secrets["carol@b4l.test"]["source_scheme"] == "BLF-CRYPT"
|
||||
assert secrets["dave@poy.test"]["hash"] is None
|
||||
assert client.succeed("stat -c %a /root/mig/secrets.json").strip() == "600"
|
||||
mig("inventory", ok=False) # refuses to clobber a reviewed plan
|
||||
|
||||
with subtest("2. users"):
|
||||
print(mig("users --generate-missing"))
|
||||
client.succeed(
|
||||
"diff -u <(grep -v '^ *#' ${./expected-mail-accounts.nix}) <(grep -v '^ *#' /root/mig/out/mail-accounts.nix)"
|
||||
)
|
||||
client.succeed("diff -u ${./expected-dovecot-master-user.nix} /root/mig/out/dovecot-master-user.nix")
|
||||
for login in ["alice@b4l.test", "bob@b4l.test", "carol@b4l.test", "dave@poy.test"]:
|
||||
h = client.succeed(f"cat '/root/mig/out/hashes/{login}.hash'").strip()
|
||||
new.succeed(f"printf '%s\\n' '{h}' > '/var/lib/mailmig-hashes/{login}.hash'")
|
||||
new.succeed("systemctl restart dovecot.service")
|
||||
new.wait_for_open_port(993)
|
||||
# Users keep their Stalwart passwords; dave gets the generated one.
|
||||
fixture("login-new alice@b4l.test alicepass")
|
||||
fixture("login-new bob@b4l.test bobpass")
|
||||
fixture("login-new carol@b4l.test carolpass")
|
||||
dave_pw = client.succeed("grep ^dave@poy.test: /root/mig/out/generated-passwords.txt | cut -d: -f2-").strip()
|
||||
fixture(f"login-new dave@poy.test '{dave_pw}'")
|
||||
client.fail("python3 ${./imap_fixture.py} login-new alice@b4l.test wrongpass")
|
||||
print(client.succeed("cat /root/mig/out/clan-vars-set.sh"))
|
||||
|
||||
with subtest("3. check"):
|
||||
print(mig("check"))
|
||||
|
||||
with subtest("4. sync"):
|
||||
print(mig("sync --dry-run --report dry.json"))
|
||||
assert total(report("dry.json"), "copied") == 0
|
||||
mig("verify --report v0.json", ok=False) # missing mail -> exit 1
|
||||
print(mig("sync --report s1.json"))
|
||||
s1 = report("s1.json")
|
||||
assert all(a["ok"] for a in s1), s1
|
||||
# 6 in alice's INBOX (\Deleted skipped) + 2 sent + 3 folders + bob 2 + carol 1 + dave 1
|
||||
assert total(s1, "copied") == 15, total(s1, "copied")
|
||||
assert total(s1, "skipped_deleted") == 1
|
||||
print(mig("verify --report v1.json"))
|
||||
|
||||
box = json.loads(fixture("inspect alice@b4l.test alicepass"))
|
||||
print(json.dumps(box, indent=1))
|
||||
f = box["folders"]
|
||||
for name in ["INBOX", "Sent", "Junk", "Drafts", "Projects.2024", "Projects.v1_2", "Caf&AOk-"]:
|
||||
assert name in box["boxes"], (name, box["boxes"])
|
||||
# Stalwart's special-use folders land on SNM's names.
|
||||
mapped = {fr["src"]: fr["dst"] for a in s1 for fr in a["folders"] if a["login"] == "alice@b4l.test"}
|
||||
print("folder mapping:", mapped)
|
||||
assert mapped["Sent Items"] == "Sent"
|
||||
for src, dst in [("Deleted Items", "Trash"), ("Junk Mail", "Junk")]:
|
||||
if src in mapped:
|
||||
assert mapped[src] == dst, (src, mapped[src])
|
||||
inbox = {m["mid"]: m for m in f["INBOX"]}
|
||||
assert len(f["INBOX"]) == 6, f["INBOX"]
|
||||
assert "del@test" not in inbox
|
||||
assert [m["mid"] for m in f["INBOX"]].count("dup@test") == 2
|
||||
assert None in inbox # message without Message-ID
|
||||
assert inbox["m1@test"]["flags"] == ["\\Flagged", "\\Seen"], inbox["m1@test"]
|
||||
assert inbox["m1@test"]["date"] == "01-Jan-2020 10:00:00 +0000", inbox["m1@test"]
|
||||
assert "$Forwarded" in inbox["m2@test"]["flags"]
|
||||
assert inbox["big@test"]["size"] > 3000000
|
||||
assert [m["mid"] for m in f["Sent"]] == ["s1@test", "s2@test"]
|
||||
assert [m["mid"] for m in f["Caf&AOk-"]] == ["c1@test"]
|
||||
dave = json.loads(fixture(f"inspect dave@poy.test '{dave_pw}'"))
|
||||
assert [m["mid"] for m in dave["folders"]["INBOX"]] == ["dave1@test"]
|
||||
|
||||
with subtest("5. idempotent, delta and flag passes"):
|
||||
print(mig("sync --report s2.json"))
|
||||
assert total(report("s2.json"), "copied") == 0
|
||||
fixture("late")
|
||||
print(mig("sync --sync-flags --report s3.json"))
|
||||
s3 = report("s3.json")
|
||||
assert total(s3, "copied") == 1, s3
|
||||
assert total(s3, "flags_updated") == 1, s3
|
||||
inbox = {m["mid"]: m for m in json.loads(fixture("inspect alice@b4l.test alicepass"))["folders"]["INBOX"]}
|
||||
assert "late@test" in inbox
|
||||
assert "\\Seen" in inbox["m2@test"]["flags"]
|
||||
print(mig("verify --report v2.json"))
|
||||
|
||||
with subtest("6. dns zone from SNM's DKIM keys"):
|
||||
client.succeed("mkdir -p /root/mig/dkim")
|
||||
for d in ["b4l.test", "poy.test"]:
|
||||
key = new.succeed(f"cat /var/dkim/{d}.mail.txt")
|
||||
client.succeed(f"cat > /root/mig/dkim/{d}.mail.txt <<'EOF'\n{key}\nEOF")
|
||||
zone = mig("dns zone")
|
||||
print(zone)
|
||||
assert "@ IN MX 10 new.test." in zone
|
||||
assert "mail._domainkey IN TXT" in zone and "v=DKIM1" in zone
|
||||
assert "_imaps._tcp IN SRV 0 1 993 new.test." in zone
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# TEMPORARY: Dovecot master user for the mail migration (mailmig).
|
||||
# Lets "<account>*mailmig" log in to any mailbox with the master password.
|
||||
# Import this module on the SNM host only while migrating, then remove it.
|
||||
#
|
||||
# Create the passwd file on the host (root, 0640 root:dovecot2):
|
||||
# printf 'mailmig:{CRYPT}%s\n' "$(mkpasswd -m sha-512)" > /var/lib/dovecot-master-users
|
||||
{ ... }:
|
||||
{
|
||||
services.dovecot2.settings = {
|
||||
auth_master_user_separator = "*";
|
||||
"passdb mailmig-master" = {
|
||||
driver = "passwd-file";
|
||||
passwd_file_path = "/var/lib/dovecot-master-users";
|
||||
master = true;
|
||||
# After the master password checks out, still require the target
|
||||
# account to exist in SNM's own passdb.
|
||||
result_success = "continue";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Golden output of `mailmig users` for the e2e fixture (comment lines are
|
||||
# ignored by the comparison). The `new` test node also imports this file, so
|
||||
# the test proves the generated Nix evaluates and works with SNM.
|
||||
{
|
||||
domains = [
|
||||
"b4l.test"
|
||||
"poy.test"
|
||||
];
|
||||
|
||||
accounts = {
|
||||
"alice@b4l.test" = {
|
||||
aliases = [
|
||||
"a.lias@poy.test"
|
||||
];
|
||||
quota = "2G";
|
||||
};
|
||||
"bob@b4l.test" = {
|
||||
aliases = [ ];
|
||||
};
|
||||
"carol@b4l.test" = {
|
||||
aliases = [ ];
|
||||
};
|
||||
"dave@poy.test" = {
|
||||
aliases = [ ];
|
||||
};
|
||||
};
|
||||
|
||||
forwards = {
|
||||
"team@b4l.test" = [
|
||||
"alice@b4l.test"
|
||||
"bob@b4l.test"
|
||||
"ext@example.org"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"""IMAP helpers for the e2e test: seed the old server, inspect the new one."""
|
||||
import imaplib
|
||||
import json
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
CTX = ssl.create_default_context() # trusts the test CA via security.pki
|
||||
|
||||
|
||||
def old(user, password):
|
||||
c = imaplib.IMAP4("old.test", 143)
|
||||
c.starttls(ssl_context=CTX)
|
||||
c.login(user, password)
|
||||
return c
|
||||
|
||||
|
||||
def new(user, password):
|
||||
c = imaplib.IMAP4_SSL("new.test", 993, ssl_context=CTX)
|
||||
c.login(user, password)
|
||||
return c
|
||||
|
||||
|
||||
def msg(mid, subject, body="hello\r\n", extra=""):
|
||||
hdr = f"From: Sender <sender@example.org>\r\nTo: x@b4l.test\r\nSubject: {subject}\r\nDate: Wed, 01 Jan 2020 10:00:00 +0000\r\n"
|
||||
if mid:
|
||||
hdr += f"Message-ID: <{mid}>\r\n"
|
||||
return (hdr + extra + "\r\n" + body).encode()
|
||||
|
||||
|
||||
def ensure(c, box):
|
||||
typ, _ = c.create(box)
|
||||
print("create", box, typ)
|
||||
|
||||
|
||||
def append(c, box, data, flags="", date=None):
|
||||
typ, resp = c.append(box, flags or None, date, data)
|
||||
assert typ == "OK", (box, typ, resp)
|
||||
|
||||
|
||||
def seed():
|
||||
a = old("alice", "alicepass")
|
||||
append(a, "INBOX", msg("m1@test", "seen and flagged"), r"(\Seen \Flagged)", '"01-Jan-2020 10:00:00 +0000"')
|
||||
append(a, "INBOX", msg("m2@test", "forwarded"), "($Forwarded)")
|
||||
append(a, "INBOX", msg(None, "no message id"))
|
||||
append(a, "INBOX", msg("dup@test", "duplicate"))
|
||||
append(a, "INBOX", msg("dup@test", "duplicate"))
|
||||
append(a, "INBOX", msg("del@test", "deleted"), r"(\Deleted)")
|
||||
big = "\r\n".join(["x" * 76] * 40000) + "\r\n" # ~3 MB
|
||||
append(a, "INBOX", msg("big@test", "big", body=big))
|
||||
ensure(a, '"Sent Items"')
|
||||
append(a, '"Sent Items"', msg("s1@test", "sent 1"), r"(\Seen)")
|
||||
append(a, '"Sent Items"', msg("s2@test", "sent 2"), r"(\Seen)")
|
||||
ensure(a, "Projects/2024")
|
||||
append(a, "Projects/2024", msg("p1@test", "project"))
|
||||
ensure(a, "Projects/v1.2")
|
||||
append(a, "Projects/v1.2", msg("p2@test", "dotted"))
|
||||
ensure(a, "Caf&AOk-")
|
||||
append(a, "Caf&AOk-", msg("c1@test", "unicode folder"))
|
||||
print(a.list())
|
||||
a.logout()
|
||||
|
||||
b = old("bob@b4l.test", "bobpass")
|
||||
append(b, "INBOX", msg("b1@test", "bob 1"))
|
||||
append(b, "INBOX", msg("b2@test", "bob 2"), r"(\Seen)")
|
||||
b.logout()
|
||||
|
||||
c = old("carol", "carolpass")
|
||||
append(c, "INBOX", msg("carol1@test", "carol"))
|
||||
c.logout()
|
||||
|
||||
d = old("dave%migrator", "migratorpass")
|
||||
append(d, "INBOX", msg("dave1@test", "dave"))
|
||||
d.logout()
|
||||
|
||||
|
||||
def late():
|
||||
"""Delta pass: a new message arrives and an old one gets read."""
|
||||
a = old("alice", "alicepass")
|
||||
append(a, "INBOX", msg("late@test", "arrived after the bulk copy"))
|
||||
a.select("INBOX")
|
||||
typ, data = a.search(None, "HEADER", "Message-ID", "<m2@test>")
|
||||
a.store(data[0].split()[0], "+FLAGS", r"(\Seen)")
|
||||
a.logout()
|
||||
|
||||
|
||||
def inspect(user, password):
|
||||
"""Print a JSON summary of a mailbox on the new server."""
|
||||
c = new(user, password)
|
||||
typ, lst = c.list()
|
||||
boxes = []
|
||||
for line in lst:
|
||||
m = re.match(rb'\((.*?)\) "(.*?)" "?(.*?)"?$', line)
|
||||
boxes.append(m.group(3).decode())
|
||||
out = {"boxes": boxes, "folders": {}}
|
||||
for b in boxes:
|
||||
typ, _ = c.select(f'"{b}"', readonly=True)
|
||||
if typ != "OK":
|
||||
continue
|
||||
typ, data = c.fetch("1:*", "(FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID SUBJECT)])")
|
||||
msgs = []
|
||||
for item in data:
|
||||
if not isinstance(item, tuple):
|
||||
continue
|
||||
meta, hdr = item
|
||||
flags = re.search(rb"FLAGS \((.*?)\)", meta).group(1).decode().split()
|
||||
date = re.search(rb'INTERNALDATE "(.*?)"', meta).group(1).decode()
|
||||
size = int(re.search(rb"RFC822.SIZE (\d+)", meta).group(1))
|
||||
mid = re.search(rb"Message-ID: <(.*?)>", hdr, re.I)
|
||||
msgs.append({"mid": mid.group(1).decode() if mid else None, "flags": sorted(flags), "date": date, "size": size})
|
||||
out["folders"][b] = msgs
|
||||
c.logout()
|
||||
print(json.dumps(out, indent=1))
|
||||
|
||||
|
||||
def login_new(user, password):
|
||||
new(user, password).logout()
|
||||
print("login ok", user)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "seed":
|
||||
seed()
|
||||
elif cmd == "late":
|
||||
late()
|
||||
elif cmd == "inspect":
|
||||
inspect(sys.argv[2], sys.argv[3])
|
||||
elif cmd == "login-new":
|
||||
login_new(sys.argv[2], sys.argv[3])
|
||||
else:
|
||||
raise SystemExit(f"unknown command {cmd}")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Create the fixture directory on the old (Stalwart 0.15) server via its REST API."""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
API = "http://old.test:8080"
|
||||
AUTH = "Basic " + base64.b64encode(b"admin:adminpass").decode()
|
||||
|
||||
alice_hash, carol_hash = sys.argv[1], sys.argv[2]
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
req = urllib.request.Request(
|
||||
API + path,
|
||||
method=method,
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
headers={"Content-Type": "application/json", "Authorization": AUTH},
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
out = json.load(r)
|
||||
if isinstance(out, dict) and "error" in out:
|
||||
raise SystemExit(f"{method} {path}: {out}")
|
||||
print(method, path, "->", out)
|
||||
return out
|
||||
|
||||
|
||||
def principal(**kw):
|
||||
p = {
|
||||
"quota": 0, "description": "", "secrets": [], "emails": [], "urls": [], "memberOf": [],
|
||||
"roles": [], "lists": [], "members": [], "enabledPermissions": [], "disabledPermissions": [],
|
||||
"externalMembers": [],
|
||||
}
|
||||
p.update(kw)
|
||||
return call("POST", "/api/principal", p)
|
||||
|
||||
|
||||
principal(type="domain", name="b4l.test")
|
||||
principal(type="domain", name="poy.test")
|
||||
principal(type="individual", name="alice", description="Alice Example", secrets=[alice_hash],
|
||||
emails=["alice@b4l.test", "a.lias@poy.test"], quota=2 * 1024 ** 3, roles=["user"])
|
||||
# plaintext secret: Stalwart accepts unhashed secrets
|
||||
principal(type="individual", name="bob@b4l.test", secrets=["bobpass"], emails=["bob@b4l.test"], roles=["user"])
|
||||
principal(type="individual", name="carol", secrets=[carol_hash], emails=["carol@b4l.test"], roles=["user"])
|
||||
# no password at all: only reachable through the master user
|
||||
principal(type="individual", name="dave", emails=["dave@poy.test"], roles=["user"])
|
||||
principal(type="list", name="team", emails=["team@b4l.test"], members=["alice", "bob@b4l.test"],
|
||||
externalMembers=["ext@example.org"])
|
||||
Reference in new issue
Block a user