diff --git a/Cargo.lock b/Cargo.lock index 2efe9e8..8b04332 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1799,14 +1799,18 @@ name = "mailpot-cli" version = "0.1.1" dependencies = [ "assert_cmd", + "base64 0.21.0", "clap", "clap_mangen", "log", "mailpot", "mailpot-tests", "predicates", + "serde", + "serde_json", "stderrlog", "tempfile", + "ureq", ] [[package]] @@ -3222,6 +3226,18 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "ureq" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338b31dd1314f68f3aabf3ed57ab922df95ffcd902476ca7ba3c4ce7b908c46d" +dependencies = [ + "base64 0.13.1", + "log", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.3.1" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 9caadea..54a3638 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -16,10 +16,14 @@ name = "mpot" path = "src/main.rs" [dependencies] +base64 = { version = "0.21" } clap = { version = "^4.2", default-features = false, features = ["derive", "cargo", "unicode", "help", "usage", "error-context", "suggestions"] } log = "0.4" mailpot = { version = "^0.1", path = "../core" } +serde = { version = "^1", features = ["derive", ] } +serde_json = "^1" stderrlog = "^0.5" +ureq = { version = "2.6", default-features = false } [dev-dependencies] assert_cmd = "2" diff --git a/cli/build.rs b/cli/build.rs index 0f3e9a4..568d926 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -27,7 +27,7 @@ use clap::ArgAction; use clap_mangen::{roff, Man}; use roff::{bold, italic, roman, Inline, Roff}; -include!("src/lib.rs"); +include!("src/args.rs"); fn main() -> std::io::Result<()> { println!("cargo:rerun-if-changed=./src/lib.rs"); diff --git a/cli/src/args.rs b/cli/src/args.rs new file mode 100644 index 0000000..d3f79d9 --- /dev/null +++ b/cli/src/args.rs @@ -0,0 +1,496 @@ +/* + * This file is part of mailpot + * + * Copyright 2020 - Manos Pitsidianakis + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +pub use std::path::PathBuf; + +pub use clap::{Args, CommandFactory, Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command( + name = "mpot", + about = "mailing list manager", + long_about = "Tool for mailpot mailing list management.", + before_long_help = "GNU Affero version 3 or later ", + author, + version +)] +pub struct Opt { + /// Print logs. + #[arg(short, long)] + pub debug: bool, + /// Configuration file to use. + #[arg(short, long, value_parser)] + pub config: Option, + #[command(subcommand)] + pub cmd: Command, + /// Silence all output. + #[arg(short, long)] + pub quiet: bool, + /// Verbose mode (-v, -vv, -vvv, etc). + #[arg(short, long, action = clap::ArgAction::Count)] + pub verbose: u8, + /// Debug log timestamp (sec, ms, ns, none). + #[arg(short, long)] + pub ts: Option, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Prints a sample config file to STDOUT. + /// + /// You can generate a new configuration file by writing the output to a + /// file, e.g: mpot sample-config --with-smtp > config.toml + SampleConfig { + /// Use an SMTP connection instead of a shell process. + #[arg(long)] + with_smtp: bool, + }, + /// Dumps database data to STDOUT. + DumpDatabase, + /// Lists all registered mailing lists. + ListLists, + /// Mailing list management. + List { + /// Selects mailing list to operate on. + list_id: String, + #[command(subcommand)] + cmd: ListCommand, + }, + /// Create new list. + CreateList { + /// List name. + #[arg(long)] + name: String, + /// List ID. + #[arg(long)] + id: String, + /// List e-mail address. + #[arg(long)] + address: String, + /// List description. + #[arg(long)] + description: Option, + /// List archive URL. + #[arg(long)] + archive_url: Option, + }, + /// Post message from STDIN to list. + Post { + /// Show e-mail processing result without actually consuming it. + #[arg(long)] + dry_run: bool, + }, + /// Flush outgoing e-mail queue. + FlushQueue { + /// Show e-mail processing result without actually consuming it. + #[arg(long)] + dry_run: bool, + }, + /// Mail that has not been handled properly end up in the error queue. + ErrorQueue { + #[command(subcommand)] + cmd: ErrorQueueCommand, + }, + /// Import a maildir folder into an existing list. + ImportMaildir { + /// List-ID or primary key value. + list_id: String, + /// Path to a maildir mailbox. + /// Must contain {cur, tmp, new} folders. + #[arg(long, value_parser)] + maildir_path: PathBuf, + }, + /// Update postfix maps and master.cf (probably needs root permissions). + UpdatePostfixConfig { + #[arg(short = 'p', long)] + /// Override location of master.cf file (default: + /// /etc/postfix/master.cf) + master_cf: Option, + #[clap(flatten)] + config: PostfixConfig, + }, + /// Print postfix maps and master.cf entry to STDOUT. + /// + /// Map output should be added to transport_maps and local_recipient_maps + /// parameters in postfix's main.cf. It must be saved in a plain text + /// file. To make postfix be able to read them, the postmap application + /// must be executed with the path to the map file as its sole argument. + /// + /// postmap /path/to/mylist_maps + /// + /// postmap is usually distributed along with the other postfix binaries. + /// + /// The master.cf entry must be manually appended to the master.cf file. See . + PrintPostfixConfig { + #[clap(flatten)] + config: PostfixConfig, + }, + /// All Accounts. + Accounts, + /// Account info. + AccountInfo { + /// Account address. + address: String, + }, + /// Add account. + AddAccount { + /// E-mail address. + #[arg(long)] + address: String, + /// SSH public key for authentication. + #[arg(long)] + password: String, + /// Name. + #[arg(long)] + name: Option, + /// Public key. + #[arg(long)] + public_key: Option, + #[arg(long)] + /// Is account enabled. + enabled: Option, + }, + /// Remove account. + RemoveAccount { + #[arg(long)] + /// E-mail address. + address: String, + }, + /// Update account info. + UpdateAccount { + /// Address to edit. + address: String, + /// Public key for authentication. + #[arg(long)] + password: Option, + /// Name. + #[arg(long)] + name: Option>, + /// Public key. + #[arg(long)] + public_key: Option>, + #[arg(long)] + /// Is account enabled. + enabled: Option>, + }, + /// Show and fix possible data mistakes or inconsistencies. + Repair { + /// Fix errors (default: false) + #[arg(long, default_value = "false")] + fix: bool, + /// Select all tests (default: false) + #[arg(long, default_value = "false")] + all: bool, + /// Post `datetime` column must have the Date: header value, in RFC2822 + /// format. + #[arg(long, default_value = "false")] + datetime_header_value: bool, + /// Remove accounts that have no matching subscriptions. + #[arg(long, default_value = "false")] + remove_empty_accounts: bool, + /// Remove subscription requests that have been accepted. + #[arg(long, default_value = "false")] + remove_accepted_subscription_requests: bool, + /// Warn if a list has no owners. + #[arg(long, default_value = "false")] + warn_list_no_owner: bool, + }, +} + +/// Postfix config values. +#[derive(Debug, Args)] +pub struct PostfixConfig { + /// User that runs mailpot when postfix relays a message. + /// + /// Must not be the `postfix` user. + /// Must have permissions to access the database file and the data + /// directory. + #[arg(short, long)] + pub user: String, + /// Group that runs mailpot when postfix relays a message. + /// Optional. + #[arg(short, long)] + pub group: Option, + /// The path to the mailpot binary postfix will execute. + #[arg(long)] + pub binary_path: PathBuf, + /// Limit the number of mailpot instances that can exist at the same time. + /// + /// Default is 1. + #[arg(long, default_value = "1")] + pub process_limit: Option, + /// The directory in which the map files are saved. + /// + /// Default is `data_path` from [`Configuration`](mailpot::Configuration). + #[arg(long)] + pub map_output_path: Option, + /// The name of the postfix service name to use. + /// Default is `mailpot`. + /// + /// A postfix service is a daemon managed by the postfix process. + /// Each entry in the `master.cf` configuration file defines a single + /// service. + /// + /// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html): + /// . + #[arg(long)] + pub transport_name: Option, +} + +#[derive(Debug, Subcommand)] +pub enum ErrorQueueCommand { + /// List. + List, + /// Print entry in RFC5322 or JSON format. + Print { + /// index of entry. + #[arg(long)] + index: Vec, + }, + /// Delete entry and print it in stdout. + Delete { + /// index of entry. + #[arg(long)] + index: Vec, + /// Do not print in stdout. + #[arg(long)] + quiet: bool, + }, +} + +/// Subscription options. +#[derive(Debug, Args)] +pub struct SubscriptionOptions { + /// Name. + #[arg(long)] + pub name: Option, + /// Send messages as digest. + #[arg(long, default_value = "false")] + pub digest: Option, + /// Hide message from list when posting. + #[arg(long, default_value = "false")] + pub hide_address: Option, + /// Hide message from list when posting. + #[arg(long, default_value = "false")] + /// E-mail address verification status. + pub verified: Option, + #[arg(long, default_value = "true")] + /// Receive confirmation email when posting. + pub receive_confirmation: Option, + #[arg(long, default_value = "true")] + /// Receive posts from list even if address exists in To or Cc header. + pub receive_duplicates: Option, + #[arg(long, default_value = "false")] + /// Receive own posts from list. + pub receive_own_posts: Option, + #[arg(long, default_value = "true")] + /// Is subscription enabled. + pub enabled: Option, +} + +/// Account options. +#[derive(Debug, Args)] +pub struct AccountOptions { + /// Name. + #[arg(long)] + pub name: Option, + /// Public key. + #[arg(long)] + pub public_key: Option, + #[arg(long)] + /// Is account enabled. + pub enabled: Option, +} + +#[derive(Debug, Subcommand)] +pub enum ListCommand { + /// List subscriptions of list. + Subscriptions, + /// Add subscription to list. + AddSubscription { + /// E-mail address. + #[arg(long)] + address: String, + #[clap(flatten)] + subscription_options: SubscriptionOptions, + }, + /// Remove subscription from list. + RemoveSubscription { + #[arg(long)] + /// E-mail address. + address: String, + }, + /// Update subscription info. + UpdateSubscription { + /// Address to edit. + address: String, + #[clap(flatten)] + subscription_options: SubscriptionOptions, + }, + /// Add a new post policy. + AddPolicy { + #[arg(long)] + /// Only list owners can post. + announce_only: bool, + #[arg(long)] + /// Only subscriptions can post. + subscription_only: bool, + #[arg(long)] + /// Subscriptions can post. + /// Other posts must be approved by list owners. + approval_needed: bool, + #[arg(long)] + /// Anyone can post without restrictions. + open: bool, + #[arg(long)] + /// Allow posts, but handle it manually. + custom: bool, + }, + // Remove post policy. + RemovePolicy { + #[arg(long)] + /// Post policy primary key. + pk: i64, + }, + /// Add subscription policy to list. + AddSubscribePolicy { + #[arg(long)] + /// Send confirmation e-mail when subscription is finalized. + send_confirmation: bool, + #[arg(long)] + /// Anyone can subscribe without restrictions. + open: bool, + #[arg(long)] + /// Only list owners can manually add subscriptions. + manual: bool, + #[arg(long)] + /// Anyone can request to subscribe. + request: bool, + #[arg(long)] + /// Allow subscriptions, but handle it manually. + custom: bool, + }, + RemoveSubscribePolicy { + #[arg(long)] + /// Subscribe policy primary key. + pk: i64, + }, + /// Add list owner to list. + AddListOwner { + #[arg(long)] + address: String, + #[arg(long)] + name: Option, + }, + RemoveListOwner { + #[arg(long)] + /// List owner primary key. + pk: i64, + }, + /// Alias for update-subscription --enabled true. + EnableSubscription { + /// Subscription address. + address: String, + }, + /// Alias for update-subscription --enabled false. + DisableSubscription { + /// Subscription address. + address: String, + }, + /// Update mailing list details. + Update { + /// New list name. + #[arg(long)] + name: Option, + /// New List-ID. + #[arg(long)] + id: Option, + /// New list address. + #[arg(long)] + address: Option, + /// New list description. + #[arg(long)] + description: Option, + /// New list archive URL. + #[arg(long)] + archive_url: Option, + /// New owner address local part. + /// If empty, it defaults to '+owner'. + #[arg(long)] + owner_local_part: Option, + /// New request address local part. + /// If empty, it defaults to '+request'. + #[arg(long)] + request_local_part: Option, + /// Require verification of e-mails for new subscriptions. + /// + /// Subscriptions that are initiated from the subscription's address are + /// verified automatically. + #[arg(long)] + verify: Option, + /// Public visibility of list. + /// + /// If hidden, the list will not show up in public APIs unless + /// requests to it won't work. + #[arg(long)] + hidden: Option, + /// Enable or disable the list's functionality. + /// + /// If not enabled, the list will continue to show up in the database + /// but e-mails and requests to it won't work. + #[arg(long)] + enabled: Option, + }, + /// Show mailing list health status. + Health, + /// Show mailing list info. + Info, + /// Import members in a local list from a remote mailman3 REST API instance. + /// + /// To find the id of the remote list, you can check URL/lists. + /// Example with curl: + /// + /// curl --anyauth -u admin:pass "http://localhost:9001/3.0/lists" + /// + /// If you're trying to import an entire list, create it first and then + /// import its users with this command. + /// + /// Example: + /// mpot -c conf.toml list list-general import-members --url "http://localhost:9001/3.0/" --username admin --password password --list-id list-general.example.com --skip-owners --dry-run + ImportMembers { + #[arg(long)] + /// REST HTTP endpoint e.g. http://localhost:9001/3.0/ + url: String, + #[arg(long)] + /// REST HTTP Basic Authentication username. + username: String, + #[arg(long)] + /// REST HTTP Basic Authentication password. + password: String, + #[arg(long)] + /// List ID of remote list to query. + list_id: String, + /// Show what would be inserted without performing any changes. + #[arg(long)] + dry_run: bool, + /// Don't import list owners. + #[arg(long)] + skip_owners: bool, + }, +} diff --git a/cli/src/import.rs b/cli/src/import.rs new file mode 100644 index 0000000..f7425dd --- /dev/null +++ b/cli/src/import.rs @@ -0,0 +1,149 @@ +/* + * This file is part of mailpot + * + * Copyright 2023 - Manos Pitsidianakis + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +use std::{borrow::Cow, time::Duration}; + +use base64::{engine::general_purpose, Engine as _}; +use mailpot::models::{ListOwner, ListSubscription}; +use ureq::Agent; + +pub struct Mailman3Connection { + agent: Agent, + url: Cow<'static, str>, + auth: String, +} + +impl Mailman3Connection { + pub fn new( + url: &str, + username: &str, + password: &str, + ) -> Result> { + let agent: Agent = ureq::AgentBuilder::new() + .timeout_read(Duration::from_secs(5)) + .timeout_write(Duration::from_secs(5)) + .build(); + let mut buf = String::new(); + general_purpose::STANDARD + .encode_string(format!("{username}:{password}").as_bytes(), &mut buf); + + let auth: String = format!("Basic {buf}"); + + Ok(Self { + agent, + url: url.trim_end_matches('/').to_string().into(), + auth, + }) + } + + pub fn users(&self, list_address: &str) -> Result, Box> { + let response: String = self + .agent + .get(&format!( + "{}/lists/{list_address}/roster/member?fields=email&fields=display_name", + self.url + )) + .set("Authorization", &self.auth) + .call()? + .into_string()?; + Ok(serde_json::from_str::(&response)?.entries) + } + + pub fn owners(&self, list_address: &str) -> Result, Box> { + let response: String = self + .agent + .get(&format!( + "{}/lists/{list_address}/roster/owner?fields=email&fields=display_name", + self.url + )) + .set("Authorization", &self.auth) + .call()? + .into_string()?; + Ok(serde_json::from_str::(&response)?.entries) + } +} + +#[derive(serde::Deserialize, Debug)] +pub struct Roster { + pub entries: Vec, +} + +#[derive(serde::Deserialize, Debug)] +pub struct Entry { + display_name: String, + email: String, +} + +impl Entry { + pub fn display_name(&self) -> Option<&str> { + if !self.display_name.trim().is_empty() && &self.display_name != "None" { + Some(&self.display_name) + } else { + None + } + } + + pub fn email(&self) -> &str { + &self.email + } + + pub fn into_subscription(self, list: i64) -> ListSubscription { + let Self { + display_name, + email, + } = self; + + ListSubscription { + pk: -1, + list, + address: email, + name: if !display_name.trim().is_empty() && &display_name != "None" { + Some(display_name) + } else { + None + }, + account: None, + enabled: true, + verified: true, + digest: false, + hide_address: false, + receive_duplicates: false, + receive_own_posts: false, + receive_confirmation: false, + } + } + + pub fn into_owner(self, list: i64) -> ListOwner { + let Self { + display_name, + email, + } = self; + + ListOwner { + pk: -1, + list, + address: email, + name: if !display_name.trim().is_empty() && &display_name != "None" { + Some(display_name) + } else { + None + }, + } + } +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs index b9439d7..67aad61 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -17,448 +17,11 @@ * along with this program. If not, see . */ +extern crate base64; +extern crate ureq; pub use std::path::PathBuf; +mod args; +pub mod import; +pub use args::*; pub use clap::{Args, CommandFactory, Parser, Subcommand}; - -#[derive(Debug, Parser)] -#[command( - name = "mpot", - about = "mailing list manager", - long_about = "Tool for mailpot mailing list management.", - before_long_help = "GNU Affero version 3 or later ", - author, - version -)] -pub struct Opt { - /// Print logs. - #[arg(short, long)] - pub debug: bool, - /// Configuration file to use. - #[arg(short, long, value_parser)] - pub config: Option, - #[command(subcommand)] - pub cmd: Command, - /// Silence all output. - #[arg(short, long)] - pub quiet: bool, - /// Verbose mode (-v, -vv, -vvv, etc). - #[arg(short, long, action = clap::ArgAction::Count)] - pub verbose: u8, - /// Debug log timestamp (sec, ms, ns, none). - #[arg(short, long)] - pub ts: Option, -} - -#[derive(Debug, Subcommand)] -pub enum Command { - /// Prints a sample config file to STDOUT. - /// - /// You can generate a new configuration file by writing the output to a - /// file, e.g: mpot sample-config --with-smtp > config.toml - SampleConfig { - /// Use an SMTP connection instead of a shell process. - #[arg(long)] - with_smtp: bool, - }, - /// Dumps database data to STDOUT. - DumpDatabase, - /// Lists all registered mailing lists. - ListLists, - /// Mailing list management. - List { - /// Selects mailing list to operate on. - list_id: String, - #[command(subcommand)] - cmd: ListCommand, - }, - /// Create new list. - CreateList { - /// List name. - #[arg(long)] - name: String, - /// List ID. - #[arg(long)] - id: String, - /// List e-mail address. - #[arg(long)] - address: String, - /// List description. - #[arg(long)] - description: Option, - /// List archive URL. - #[arg(long)] - archive_url: Option, - }, - /// Post message from STDIN to list. - Post { - /// Show e-mail processing result without actually consuming it. - #[arg(long)] - dry_run: bool, - }, - /// Flush outgoing e-mail queue. - FlushQueue { - /// Show e-mail processing result without actually consuming it. - #[arg(long)] - dry_run: bool, - }, - /// Mail that has not been handled properly end up in the error queue. - ErrorQueue { - #[command(subcommand)] - cmd: ErrorQueueCommand, - }, - /// Import a maildir folder into an existing list. - ImportMaildir { - /// List-ID or primary key value. - list_id: String, - /// Path to a maildir mailbox. - /// Must contain {cur, tmp, new} folders. - #[arg(long, value_parser)] - maildir_path: PathBuf, - }, - /// Update postfix maps and master.cf (probably needs root permissions). - UpdatePostfixConfig { - #[arg(short = 'p', long)] - /// Override location of master.cf file (default: - /// /etc/postfix/master.cf) - master_cf: Option, - #[clap(flatten)] - config: PostfixConfig, - }, - /// Print postfix maps and master.cf entry to STDOUT. - /// - /// Map output should be added to transport_maps and local_recipient_maps - /// parameters in postfix's main.cf. It must be saved in a plain text - /// file. To make postfix be able to read them, the postmap application - /// must be executed with the path to the map file as its sole argument. - /// - /// postmap /path/to/mylist_maps - /// - /// postmap is usually distributed along with the other postfix binaries. - /// - /// The master.cf entry must be manually appended to the master.cf file. See . - PrintPostfixConfig { - #[clap(flatten)] - config: PostfixConfig, - }, - /// All Accounts. - Accounts, - /// Account info. - AccountInfo { - /// Account address. - address: String, - }, - /// Add account. - AddAccount { - /// E-mail address. - #[arg(long)] - address: String, - /// SSH public key for authentication. - #[arg(long)] - password: String, - /// Name. - #[arg(long)] - name: Option, - /// Public key. - #[arg(long)] - public_key: Option, - #[arg(long)] - /// Is account enabled. - enabled: Option, - }, - /// Remove account. - RemoveAccount { - #[arg(long)] - /// E-mail address. - address: String, - }, - /// Update account info. - UpdateAccount { - /// Address to edit. - address: String, - /// Public key for authentication. - #[arg(long)] - password: Option, - /// Name. - #[arg(long)] - name: Option>, - /// Public key. - #[arg(long)] - public_key: Option>, - #[arg(long)] - /// Is account enabled. - enabled: Option>, - }, - /// Show and fix possible data mistakes or inconsistencies. - Repair { - /// Fix errors (default: false) - #[arg(long, default_value = "false")] - fix: bool, - /// Select all tests (default: false) - #[arg(long, default_value = "false")] - all: bool, - /// Post `datetime` column must have the Date: header value, in RFC2822 - /// format. - #[arg(long, default_value = "false")] - datetime_header_value: bool, - /// Remove accounts that have no matching subscriptions. - #[arg(long, default_value = "false")] - remove_empty_accounts: bool, - /// Remove subscription requests that have been accepted. - #[arg(long, default_value = "false")] - remove_accepted_subscription_requests: bool, - /// Warn if a list has no owners. - #[arg(long, default_value = "false")] - warn_list_no_owner: bool, - }, -} - -/// Postfix config values. -#[derive(Debug, Args)] -pub struct PostfixConfig { - /// User that runs mailpot when postfix relays a message. - /// - /// Must not be the `postfix` user. - /// Must have permissions to access the database file and the data - /// directory. - #[arg(short, long)] - pub user: String, - /// Group that runs mailpot when postfix relays a message. - /// Optional. - #[arg(short, long)] - pub group: Option, - /// The path to the mailpot binary postfix will execute. - #[arg(long)] - pub binary_path: PathBuf, - /// Limit the number of mailpot instances that can exist at the same time. - /// - /// Default is 1. - #[arg(long, default_value = "1")] - pub process_limit: Option, - /// The directory in which the map files are saved. - /// - /// Default is `data_path` from [`Configuration`](mailpot::Configuration). - #[arg(long)] - pub map_output_path: Option, - /// The name of the postfix service name to use. - /// Default is `mailpot`. - /// - /// A postfix service is a daemon managed by the postfix process. - /// Each entry in the `master.cf` configuration file defines a single - /// service. - /// - /// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html): - /// . - #[arg(long)] - pub transport_name: Option, -} - -#[derive(Debug, Subcommand)] -pub enum ErrorQueueCommand { - /// List. - List, - /// Print entry in RFC5322 or JSON format. - Print { - /// index of entry. - #[arg(long)] - index: Vec, - }, - /// Delete entry and print it in stdout. - Delete { - /// index of entry. - #[arg(long)] - index: Vec, - /// Do not print in stdout. - #[arg(long)] - quiet: bool, - }, -} - -/// Subscription options. -#[derive(Debug, Args)] -pub struct SubscriptionOptions { - /// Name. - #[arg(long)] - pub name: Option, - /// Send messages as digest. - #[arg(long, default_value = "false")] - pub digest: Option, - /// Hide message from list when posting. - #[arg(long, default_value = "false")] - pub hide_address: Option, - /// Hide message from list when posting. - #[arg(long, default_value = "false")] - /// E-mail address verification status. - pub verified: Option, - #[arg(long, default_value = "true")] - /// Receive confirmation email when posting. - pub receive_confirmation: Option, - #[arg(long, default_value = "true")] - /// Receive posts from list even if address exists in To or Cc header. - pub receive_duplicates: Option, - #[arg(long, default_value = "false")] - /// Receive own posts from list. - pub receive_own_posts: Option, - #[arg(long, default_value = "true")] - /// Is subscription enabled. - pub enabled: Option, -} - -/// Account options. -#[derive(Debug, Args)] -pub struct AccountOptions { - /// Name. - #[arg(long)] - pub name: Option, - /// Public key. - #[arg(long)] - pub public_key: Option, - #[arg(long)] - /// Is account enabled. - pub enabled: Option, -} - -#[derive(Debug, Subcommand)] -pub enum ListCommand { - /// List subscriptions of list. - Subscriptions, - /// Add subscription to list. - AddSubscription { - /// E-mail address. - #[arg(long)] - address: String, - #[clap(flatten)] - subscription_options: SubscriptionOptions, - }, - /// Remove subscription from list. - RemoveSubscription { - #[arg(long)] - /// E-mail address. - address: String, - }, - /// Update subscription info. - UpdateSubscription { - /// Address to edit. - address: String, - #[clap(flatten)] - subscription_options: SubscriptionOptions, - }, - /// Add a new post policy. - AddPolicy { - #[arg(long)] - /// Only list owners can post. - announce_only: bool, - #[arg(long)] - /// Only subscriptions can post. - subscription_only: bool, - #[arg(long)] - /// Subscriptions can post. - /// Other posts must be approved by list owners. - approval_needed: bool, - #[arg(long)] - /// Anyone can post without restrictions. - open: bool, - #[arg(long)] - /// Allow posts, but handle it manually. - custom: bool, - }, - // Remove post policy. - RemovePolicy { - #[arg(long)] - /// Post policy primary key. - pk: i64, - }, - /// Add subscription policy to list. - AddSubscribePolicy { - #[arg(long)] - /// Send confirmation e-mail when subscription is finalized. - send_confirmation: bool, - #[arg(long)] - /// Anyone can subscribe without restrictions. - open: bool, - #[arg(long)] - /// Only list owners can manually add subscriptions. - manual: bool, - #[arg(long)] - /// Anyone can request to subscribe. - request: bool, - #[arg(long)] - /// Allow subscriptions, but handle it manually. - custom: bool, - }, - RemoveSubscribePolicy { - #[arg(long)] - /// Subscribe policy primary key. - pk: i64, - }, - /// Add list owner to list. - AddListOwner { - #[arg(long)] - address: String, - #[arg(long)] - name: Option, - }, - RemoveListOwner { - #[arg(long)] - /// List owner primary key. - pk: i64, - }, - /// Alias for update-subscription --enabled true. - EnableSubscription { - /// Subscription address. - address: String, - }, - /// Alias for update-subscription --enabled false. - DisableSubscription { - /// Subscription address. - address: String, - }, - /// Update mailing list details. - Update { - /// New list name. - #[arg(long)] - name: Option, - /// New List-ID. - #[arg(long)] - id: Option, - /// New list address. - #[arg(long)] - address: Option, - /// New list description. - #[arg(long)] - description: Option, - /// New list archive URL. - #[arg(long)] - archive_url: Option, - /// New owner address local part. - /// If empty, it defaults to '+owner'. - #[arg(long)] - owner_local_part: Option, - /// New request address local part. - /// If empty, it defaults to '+request'. - #[arg(long)] - request_local_part: Option, - /// Require verification of e-mails for new subscriptions. - /// - /// Subscriptions that are initiated from the subscription's address are - /// verified automatically. - #[arg(long)] - verify: Option, - /// Public visibility of list. - /// - /// If hidden, the list will not show up in public APIs unless - /// requests to it won't work. - #[arg(long)] - hidden: Option, - /// Enable or disable the list's functionality. - /// - /// If not enabled, the list will continue to show up in the database - /// but e-mails and requests to it won't work. - #[arg(long)] - enabled: Option, - }, - /// Show mailing list health status. - Health, - /// Show mailing list info. - Info, -} diff --git a/cli/src/main.rs b/cli/src/main.rs index 0a5c90a..dc9d80b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -421,6 +421,61 @@ fn run_app(opt: Opt) -> Result<()> { }; db.update_list(changeset)?; } + ImportMembers { + url, + username, + password, + list_id, + dry_run, + skip_owners, + } => { + let conn = import::Mailman3Connection::new(&url, &username, &password).unwrap(); + if dry_run { + let entries = conn.users(&list_id).unwrap(); + println!("{} result(s)", entries.len()); + for e in entries { + println!( + "{}{}<{}>", + if let Some(n) = e.display_name() { + n + } else { + "" + }, + if e.display_name().is_none() { "" } else { " " }, + e.email() + ); + } + if !skip_owners { + let entries = conn.owners(&list_id).unwrap(); + println!("\nOwners: {} result(s)", entries.len()); + for e in entries { + println!( + "{}{}<{}>", + if let Some(n) = e.display_name() { + n + } else { + "" + }, + if e.display_name().is_none() { "" } else { " " }, + e.email() + ); + } + } + } else { + let entries = conn.users(&list_id).unwrap(); + let tx = db.transaction(Default::default()).unwrap(); + for sub in entries.into_iter().map(|e| e.into_subscription(list.pk)) { + tx.add_subscription(list.pk, sub)?; + } + if !skip_owners { + let entries = conn.owners(&list_id).unwrap(); + for sub in entries.into_iter().map(|e| e.into_owner(list.pk)) { + tx.add_list_owner(sub)?; + } + } + tx.commit()?; + } + } } } CreateList { diff --git a/core/src/connection.rs b/core/src/connection.rs index 7d3e619..ff9f2b5 100644 --- a/core/src/connection.rs +++ b/core/src/connection.rs @@ -670,4 +670,140 @@ impl Connection { tx.commit()?; Ok(()) } + + /// Execute operations inside an SQL transaction. + pub fn transaction( + &'_ self, + behavior: transaction::TransactionBehavior, + ) -> Result> { + use transaction::*; + + let query = match behavior { + TransactionBehavior::Deferred => "BEGIN DEFERRED", + TransactionBehavior::Immediate => "BEGIN IMMEDIATE", + TransactionBehavior::Exclusive => "BEGIN EXCLUSIVE", + }; + self.connection.execute_batch(query)?; + Ok(Transaction { + conn: self, + drop_behavior: DropBehavior::Rollback, + }) + } +} + +/// Execute operations inside an SQL transaction. +pub mod transaction { + use super::*; + + /// A transaction handle. + #[derive(Debug)] + pub struct Transaction<'conn> { + pub(super) conn: &'conn Connection, + pub(super) drop_behavior: DropBehavior, + } + + impl Drop for Transaction<'_> { + fn drop(&mut self) { + _ = self.finish_(); + } + } + + impl Transaction<'_> { + /// Commit and consume transaction. + pub fn commit(mut self) -> Result<()> { + self.commit_() + } + + fn commit_(&mut self) -> Result<()> { + self.conn.connection.execute_batch("COMMIT")?; + Ok(()) + } + + /// Configure the transaction to perform the specified action when it is + /// dropped. + #[inline] + pub fn set_drop_behavior(&mut self, drop_behavior: DropBehavior) { + self.drop_behavior = drop_behavior; + } + + /// A convenience method which consumes and rolls back a transaction. + #[inline] + pub fn rollback(mut self) -> Result<()> { + self.rollback_() + } + + fn rollback_(&mut self) -> Result<()> { + self.conn.connection.execute_batch("ROLLBACK")?; + Ok(()) + } + + /// Consumes the transaction, committing or rolling back according to + /// the current setting (see `drop_behavior`). + /// + /// Functionally equivalent to the `Drop` implementation, but allows + /// callers to see any errors that occur. + #[inline] + pub fn finish(mut self) -> Result<()> { + self.finish_() + } + + #[inline] + fn finish_(&mut self) -> Result<()> { + if self.conn.connection.is_autocommit() { + return Ok(()); + } + match self.drop_behavior { + DropBehavior::Commit => self.commit_().or_else(|_| self.rollback_()), + DropBehavior::Rollback => self.rollback_(), + DropBehavior::Ignore => Ok(()), + DropBehavior::Panic => panic!("Transaction dropped unexpectedly."), + } + } + } + + impl std::ops::Deref for Transaction<'_> { + type Target = Connection; + + #[inline] + fn deref(&self) -> &Connection { + self.conn + } + } + + /// Options for transaction behavior. See [BEGIN + /// TRANSACTION](http://www.sqlite.org/lang_transaction.html) for details. + #[derive(Copy, Clone, Default)] + #[non_exhaustive] + pub enum TransactionBehavior { + /// DEFERRED means that the transaction does not actually start until + /// the database is first accessed. + Deferred, + /// IMMEDIATE cause the database connection to start a new write + /// immediately, without waiting for a writes statement. + Immediate, + #[default] + /// EXCLUSIVE prevents other database connections from reading the + /// database while the transaction is underway. + Exclusive, + } + + /// Options for how a Transaction or Savepoint should behave when it is + /// dropped. + #[derive(Default, Copy, Clone, Debug, PartialEq, Eq)] + #[non_exhaustive] + pub enum DropBehavior { + #[default] + /// Roll back the changes. This is the default. + Rollback, + + /// Commit the changes. + Commit, + + /// Do not commit or roll back changes - this will leave the transaction + /// or savepoint open, so should be used with care. + Ignore, + + /// Panic. Used to enforce intentional behavior during development. + Panic, + } } diff --git a/docs/mpot.1 b/docs/mpot.1 index 888ba26..02a0504 100644 --- a/docs/mpot.1 +++ b/docs/mpot.1 @@ -569,6 +569,37 @@ Show mailing list info. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\fB +.SS mpot list import-members +.\fR +.br + +.br + +mpot list import\-members \-\-url \fIURL\fR \-\-username \fIUSERNAME\fR \-\-password \fIPASSWORD\fR \-\-list\-id \fILIST_ID\fR [\-\-dry\-run \fIDRY_RUN\fR] [\-\-skip\-owners \fISKIP_OWNERS\fR] +.br + +Import members in a local list from a remote mailman3 REST API instance. +.TP +\-\-url \fIURL\fR +REST HTTP endpoint e.g. http://localhost:9001/3.0/. +.TP +\-\-username \fIUSERNAME\fR +REST HTTP Basic Authentication username. +.TP +\-\-password \fIPASSWORD\fR +REST HTTP Basic Authentication password. +.TP +\-\-list\-id \fILIST_ID\fR +List ID of remote list to query. +.TP +\-\-dry\-run +Show what would be inserted without performing any changes. +.TP +\-\-skip\-owners +Don\*(Aqt import list owners. +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.\fB .SS mpot create-list .\fR .br