mailmig: Stalwart -> SNM IMAP migration tool (initial)

This commit is contained in:
grabowski committed 2026-10-23 10:10:57 +07:00
commit be16176b6e
24 files changed
+7804

No files matched your search

+12
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -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"
+53
View File
@@ -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
];
};
};
}
+71
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
max_width = 120
use_small_heuristics = "Max"
+396
View File
@@ -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"));
}
}
+319
View File
@@ -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, &current);
// 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);
}
}
+71
View File
@@ -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
View File
@@ -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, &current);
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."));
}
}
+677
View File
@@ -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"));
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod client;
pub mod proto;
pub mod utf7;
pub use client::{Client, Login, Mailbox, MsgMeta, Namespaces, SelectInfo};
+388
View File
@@ -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);
}
}
+119
View File
@@ -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
View File
@@ -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)?, &current);
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)?, &current);
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
'';
}
+20
View File
@@ -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";
};
};
}
+35
View File
@@ -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"
];
};
}
+132
View File
@@ -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}")
+48
View File
@@ -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"])