Author: Manos Pitsidianakis [manos@pitsidianak.is]
Hash: f0bf147a0d086d4ad8d0de0978adabfc3f921e78
Timestamp: Tue, 09 May 2023 07:49:54 +0000 (1 year ago)

+893 -443 +/-9 browse
cli: add import from mailman3 rest api
1diff --git a/Cargo.lock b/Cargo.lock
2index 2efe9e8..8b04332 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -1799,14 +1799,18 @@ name = "mailpot-cli"
6 version = "0.1.1"
7 dependencies = [
8 "assert_cmd",
9+ "base64 0.21.0",
10 "clap",
11 "clap_mangen",
12 "log",
13 "mailpot",
14 "mailpot-tests",
15 "predicates",
16+ "serde",
17+ "serde_json",
18 "stderrlog",
19 "tempfile",
20+ "ureq",
21 ]
22
23 [[package]]
24 @@ -3223,6 +3227,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
25 checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
26
27 [[package]]
28+ name = "ureq"
29+ version = "2.6.2"
30+ source = "registry+https://github.com/rust-lang/crates.io-index"
31+ checksum = "338b31dd1314f68f3aabf3ed57ab922df95ffcd902476ca7ba3c4ce7b908c46d"
32+ dependencies = [
33+ "base64 0.13.1",
34+ "log",
35+ "once_cell",
36+ "url",
37+ ]
38+
39+ [[package]]
40 name = "url"
41 version = "2.3.1"
42 source = "registry+https://github.com/rust-lang/crates.io-index"
43 diff --git a/cli/Cargo.toml b/cli/Cargo.toml
44index 9caadea..54a3638 100644
45--- a/cli/Cargo.toml
46+++ b/cli/Cargo.toml
47 @@ -16,10 +16,14 @@ name = "mpot"
48 path = "src/main.rs"
49
50 [dependencies]
51+ base64 = { version = "0.21" }
52 clap = { version = "^4.2", default-features = false, features = ["derive", "cargo", "unicode", "help", "usage", "error-context", "suggestions"] }
53 log = "0.4"
54 mailpot = { version = "^0.1", path = "../core" }
55+ serde = { version = "^1", features = ["derive", ] }
56+ serde_json = "^1"
57 stderrlog = "^0.5"
58+ ureq = { version = "2.6", default-features = false }
59
60 [dev-dependencies]
61 assert_cmd = "2"
62 diff --git a/cli/build.rs b/cli/build.rs
63index 0f3e9a4..568d926 100644
64--- a/cli/build.rs
65+++ b/cli/build.rs
66 @@ -27,7 +27,7 @@ use clap::ArgAction;
67 use clap_mangen::{roff, Man};
68 use roff::{bold, italic, roman, Inline, Roff};
69
70- include!("src/lib.rs");
71+ include!("src/args.rs");
72
73 fn main() -> std::io::Result<()> {
74 println!("cargo:rerun-if-changed=./src/lib.rs");
75 diff --git a/cli/src/args.rs b/cli/src/args.rs
76new file mode 100644
77index 0000000..d3f79d9
78--- /dev/null
79+++ b/cli/src/args.rs
80 @@ -0,0 +1,496 @@
81+ /*
82+ * This file is part of mailpot
83+ *
84+ * Copyright 2020 - Manos Pitsidianakis
85+ *
86+ * This program is free software: you can redistribute it and/or modify
87+ * it under the terms of the GNU Affero General Public License as
88+ * published by the Free Software Foundation, either version 3 of the
89+ * License, or (at your option) any later version.
90+ *
91+ * This program is distributed in the hope that it will be useful,
92+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
93+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
94+ * GNU Affero General Public License for more details.
95+ *
96+ * You should have received a copy of the GNU Affero General Public License
97+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
98+ */
99+
100+ pub use std::path::PathBuf;
101+
102+ pub use clap::{Args, CommandFactory, Parser, Subcommand};
103+
104+ #[derive(Debug, Parser)]
105+ #[command(
106+ name = "mpot",
107+ about = "mailing list manager",
108+ long_about = "Tool for mailpot mailing list management.",
109+ before_long_help = "GNU Affero version 3 or later <https://www.gnu.org/licenses/>",
110+ author,
111+ version
112+ )]
113+ pub struct Opt {
114+ /// Print logs.
115+ #[arg(short, long)]
116+ pub debug: bool,
117+ /// Configuration file to use.
118+ #[arg(short, long, value_parser)]
119+ pub config: Option<PathBuf>,
120+ #[command(subcommand)]
121+ pub cmd: Command,
122+ /// Silence all output.
123+ #[arg(short, long)]
124+ pub quiet: bool,
125+ /// Verbose mode (-v, -vv, -vvv, etc).
126+ #[arg(short, long, action = clap::ArgAction::Count)]
127+ pub verbose: u8,
128+ /// Debug log timestamp (sec, ms, ns, none).
129+ #[arg(short, long)]
130+ pub ts: Option<stderrlog::Timestamp>,
131+ }
132+
133+ #[derive(Debug, Subcommand)]
134+ pub enum Command {
135+ /// Prints a sample config file to STDOUT.
136+ ///
137+ /// You can generate a new configuration file by writing the output to a
138+ /// file, e.g: mpot sample-config --with-smtp > config.toml
139+ SampleConfig {
140+ /// Use an SMTP connection instead of a shell process.
141+ #[arg(long)]
142+ with_smtp: bool,
143+ },
144+ /// Dumps database data to STDOUT.
145+ DumpDatabase,
146+ /// Lists all registered mailing lists.
147+ ListLists,
148+ /// Mailing list management.
149+ List {
150+ /// Selects mailing list to operate on.
151+ list_id: String,
152+ #[command(subcommand)]
153+ cmd: ListCommand,
154+ },
155+ /// Create new list.
156+ CreateList {
157+ /// List name.
158+ #[arg(long)]
159+ name: String,
160+ /// List ID.
161+ #[arg(long)]
162+ id: String,
163+ /// List e-mail address.
164+ #[arg(long)]
165+ address: String,
166+ /// List description.
167+ #[arg(long)]
168+ description: Option<String>,
169+ /// List archive URL.
170+ #[arg(long)]
171+ archive_url: Option<String>,
172+ },
173+ /// Post message from STDIN to list.
174+ Post {
175+ /// Show e-mail processing result without actually consuming it.
176+ #[arg(long)]
177+ dry_run: bool,
178+ },
179+ /// Flush outgoing e-mail queue.
180+ FlushQueue {
181+ /// Show e-mail processing result without actually consuming it.
182+ #[arg(long)]
183+ dry_run: bool,
184+ },
185+ /// Mail that has not been handled properly end up in the error queue.
186+ ErrorQueue {
187+ #[command(subcommand)]
188+ cmd: ErrorQueueCommand,
189+ },
190+ /// Import a maildir folder into an existing list.
191+ ImportMaildir {
192+ /// List-ID or primary key value.
193+ list_id: String,
194+ /// Path to a maildir mailbox.
195+ /// Must contain {cur, tmp, new} folders.
196+ #[arg(long, value_parser)]
197+ maildir_path: PathBuf,
198+ },
199+ /// Update postfix maps and master.cf (probably needs root permissions).
200+ UpdatePostfixConfig {
201+ #[arg(short = 'p', long)]
202+ /// Override location of master.cf file (default:
203+ /// /etc/postfix/master.cf)
204+ master_cf: Option<PathBuf>,
205+ #[clap(flatten)]
206+ config: PostfixConfig,
207+ },
208+ /// Print postfix maps and master.cf entry to STDOUT.
209+ ///
210+ /// Map output should be added to transport_maps and local_recipient_maps
211+ /// parameters in postfix's main.cf. It must be saved in a plain text
212+ /// file. To make postfix be able to read them, the postmap application
213+ /// must be executed with the path to the map file as its sole argument.
214+ ///
215+ /// postmap /path/to/mylist_maps
216+ ///
217+ /// postmap is usually distributed along with the other postfix binaries.
218+ ///
219+ /// The master.cf entry must be manually appended to the master.cf file. See <https://www.postfix.org/master.5.html>.
220+ PrintPostfixConfig {
221+ #[clap(flatten)]
222+ config: PostfixConfig,
223+ },
224+ /// All Accounts.
225+ Accounts,
226+ /// Account info.
227+ AccountInfo {
228+ /// Account address.
229+ address: String,
230+ },
231+ /// Add account.
232+ AddAccount {
233+ /// E-mail address.
234+ #[arg(long)]
235+ address: String,
236+ /// SSH public key for authentication.
237+ #[arg(long)]
238+ password: String,
239+ /// Name.
240+ #[arg(long)]
241+ name: Option<String>,
242+ /// Public key.
243+ #[arg(long)]
244+ public_key: Option<String>,
245+ #[arg(long)]
246+ /// Is account enabled.
247+ enabled: Option<bool>,
248+ },
249+ /// Remove account.
250+ RemoveAccount {
251+ #[arg(long)]
252+ /// E-mail address.
253+ address: String,
254+ },
255+ /// Update account info.
256+ UpdateAccount {
257+ /// Address to edit.
258+ address: String,
259+ /// Public key for authentication.
260+ #[arg(long)]
261+ password: Option<String>,
262+ /// Name.
263+ #[arg(long)]
264+ name: Option<Option<String>>,
265+ /// Public key.
266+ #[arg(long)]
267+ public_key: Option<Option<String>>,
268+ #[arg(long)]
269+ /// Is account enabled.
270+ enabled: Option<Option<bool>>,
271+ },
272+ /// Show and fix possible data mistakes or inconsistencies.
273+ Repair {
274+ /// Fix errors (default: false)
275+ #[arg(long, default_value = "false")]
276+ fix: bool,
277+ /// Select all tests (default: false)
278+ #[arg(long, default_value = "false")]
279+ all: bool,
280+ /// Post `datetime` column must have the Date: header value, in RFC2822
281+ /// format.
282+ #[arg(long, default_value = "false")]
283+ datetime_header_value: bool,
284+ /// Remove accounts that have no matching subscriptions.
285+ #[arg(long, default_value = "false")]
286+ remove_empty_accounts: bool,
287+ /// Remove subscription requests that have been accepted.
288+ #[arg(long, default_value = "false")]
289+ remove_accepted_subscription_requests: bool,
290+ /// Warn if a list has no owners.
291+ #[arg(long, default_value = "false")]
292+ warn_list_no_owner: bool,
293+ },
294+ }
295+
296+ /// Postfix config values.
297+ #[derive(Debug, Args)]
298+ pub struct PostfixConfig {
299+ /// User that runs mailpot when postfix relays a message.
300+ ///
301+ /// Must not be the `postfix` user.
302+ /// Must have permissions to access the database file and the data
303+ /// directory.
304+ #[arg(short, long)]
305+ pub user: String,
306+ /// Group that runs mailpot when postfix relays a message.
307+ /// Optional.
308+ #[arg(short, long)]
309+ pub group: Option<String>,
310+ /// The path to the mailpot binary postfix will execute.
311+ #[arg(long)]
312+ pub binary_path: PathBuf,
313+ /// Limit the number of mailpot instances that can exist at the same time.
314+ ///
315+ /// Default is 1.
316+ #[arg(long, default_value = "1")]
317+ pub process_limit: Option<u64>,
318+ /// The directory in which the map files are saved.
319+ ///
320+ /// Default is `data_path` from [`Configuration`](mailpot::Configuration).
321+ #[arg(long)]
322+ pub map_output_path: Option<PathBuf>,
323+ /// The name of the postfix service name to use.
324+ /// Default is `mailpot`.
325+ ///
326+ /// A postfix service is a daemon managed by the postfix process.
327+ /// Each entry in the `master.cf` configuration file defines a single
328+ /// service.
329+ ///
330+ /// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html):
331+ /// <https://www.postfix.org/master.5.html>.
332+ #[arg(long)]
333+ pub transport_name: Option<String>,
334+ }
335+
336+ #[derive(Debug, Subcommand)]
337+ pub enum ErrorQueueCommand {
338+ /// List.
339+ List,
340+ /// Print entry in RFC5322 or JSON format.
341+ Print {
342+ /// index of entry.
343+ #[arg(long)]
344+ index: Vec<i64>,
345+ },
346+ /// Delete entry and print it in stdout.
347+ Delete {
348+ /// index of entry.
349+ #[arg(long)]
350+ index: Vec<i64>,
351+ /// Do not print in stdout.
352+ #[arg(long)]
353+ quiet: bool,
354+ },
355+ }
356+
357+ /// Subscription options.
358+ #[derive(Debug, Args)]
359+ pub struct SubscriptionOptions {
360+ /// Name.
361+ #[arg(long)]
362+ pub name: Option<String>,
363+ /// Send messages as digest.
364+ #[arg(long, default_value = "false")]
365+ pub digest: Option<bool>,
366+ /// Hide message from list when posting.
367+ #[arg(long, default_value = "false")]
368+ pub hide_address: Option<bool>,
369+ /// Hide message from list when posting.
370+ #[arg(long, default_value = "false")]
371+ /// E-mail address verification status.
372+ pub verified: Option<bool>,
373+ #[arg(long, default_value = "true")]
374+ /// Receive confirmation email when posting.
375+ pub receive_confirmation: Option<bool>,
376+ #[arg(long, default_value = "true")]
377+ /// Receive posts from list even if address exists in To or Cc header.
378+ pub receive_duplicates: Option<bool>,
379+ #[arg(long, default_value = "false")]
380+ /// Receive own posts from list.
381+ pub receive_own_posts: Option<bool>,
382+ #[arg(long, default_value = "true")]
383+ /// Is subscription enabled.
384+ pub enabled: Option<bool>,
385+ }
386+
387+ /// Account options.
388+ #[derive(Debug, Args)]
389+ pub struct AccountOptions {
390+ /// Name.
391+ #[arg(long)]
392+ pub name: Option<String>,
393+ /// Public key.
394+ #[arg(long)]
395+ pub public_key: Option<String>,
396+ #[arg(long)]
397+ /// Is account enabled.
398+ pub enabled: Option<bool>,
399+ }
400+
401+ #[derive(Debug, Subcommand)]
402+ pub enum ListCommand {
403+ /// List subscriptions of list.
404+ Subscriptions,
405+ /// Add subscription to list.
406+ AddSubscription {
407+ /// E-mail address.
408+ #[arg(long)]
409+ address: String,
410+ #[clap(flatten)]
411+ subscription_options: SubscriptionOptions,
412+ },
413+ /// Remove subscription from list.
414+ RemoveSubscription {
415+ #[arg(long)]
416+ /// E-mail address.
417+ address: String,
418+ },
419+ /// Update subscription info.
420+ UpdateSubscription {
421+ /// Address to edit.
422+ address: String,
423+ #[clap(flatten)]
424+ subscription_options: SubscriptionOptions,
425+ },
426+ /// Add a new post policy.
427+ AddPolicy {
428+ #[arg(long)]
429+ /// Only list owners can post.
430+ announce_only: bool,
431+ #[arg(long)]
432+ /// Only subscriptions can post.
433+ subscription_only: bool,
434+ #[arg(long)]
435+ /// Subscriptions can post.
436+ /// Other posts must be approved by list owners.
437+ approval_needed: bool,
438+ #[arg(long)]
439+ /// Anyone can post without restrictions.
440+ open: bool,
441+ #[arg(long)]
442+ /// Allow posts, but handle it manually.
443+ custom: bool,
444+ },
445+ // Remove post policy.
446+ RemovePolicy {
447+ #[arg(long)]
448+ /// Post policy primary key.
449+ pk: i64,
450+ },
451+ /// Add subscription policy to list.
452+ AddSubscribePolicy {
453+ #[arg(long)]
454+ /// Send confirmation e-mail when subscription is finalized.
455+ send_confirmation: bool,
456+ #[arg(long)]
457+ /// Anyone can subscribe without restrictions.
458+ open: bool,
459+ #[arg(long)]
460+ /// Only list owners can manually add subscriptions.
461+ manual: bool,
462+ #[arg(long)]
463+ /// Anyone can request to subscribe.
464+ request: bool,
465+ #[arg(long)]
466+ /// Allow subscriptions, but handle it manually.
467+ custom: bool,
468+ },
469+ RemoveSubscribePolicy {
470+ #[arg(long)]
471+ /// Subscribe policy primary key.
472+ pk: i64,
473+ },
474+ /// Add list owner to list.
475+ AddListOwner {
476+ #[arg(long)]
477+ address: String,
478+ #[arg(long)]
479+ name: Option<String>,
480+ },
481+ RemoveListOwner {
482+ #[arg(long)]
483+ /// List owner primary key.
484+ pk: i64,
485+ },
486+ /// Alias for update-subscription --enabled true.
487+ EnableSubscription {
488+ /// Subscription address.
489+ address: String,
490+ },
491+ /// Alias for update-subscription --enabled false.
492+ DisableSubscription {
493+ /// Subscription address.
494+ address: String,
495+ },
496+ /// Update mailing list details.
497+ Update {
498+ /// New list name.
499+ #[arg(long)]
500+ name: Option<String>,
501+ /// New List-ID.
502+ #[arg(long)]
503+ id: Option<String>,
504+ /// New list address.
505+ #[arg(long)]
506+ address: Option<String>,
507+ /// New list description.
508+ #[arg(long)]
509+ description: Option<String>,
510+ /// New list archive URL.
511+ #[arg(long)]
512+ archive_url: Option<String>,
513+ /// New owner address local part.
514+ /// If empty, it defaults to '+owner'.
515+ #[arg(long)]
516+ owner_local_part: Option<String>,
517+ /// New request address local part.
518+ /// If empty, it defaults to '+request'.
519+ #[arg(long)]
520+ request_local_part: Option<String>,
521+ /// Require verification of e-mails for new subscriptions.
522+ ///
523+ /// Subscriptions that are initiated from the subscription's address are
524+ /// verified automatically.
525+ #[arg(long)]
526+ verify: Option<bool>,
527+ /// Public visibility of list.
528+ ///
529+ /// If hidden, the list will not show up in public APIs unless
530+ /// requests to it won't work.
531+ #[arg(long)]
532+ hidden: Option<bool>,
533+ /// Enable or disable the list's functionality.
534+ ///
535+ /// If not enabled, the list will continue to show up in the database
536+ /// but e-mails and requests to it won't work.
537+ #[arg(long)]
538+ enabled: Option<bool>,
539+ },
540+ /// Show mailing list health status.
541+ Health,
542+ /// Show mailing list info.
543+ Info,
544+ /// Import members in a local list from a remote mailman3 REST API instance.
545+ ///
546+ /// To find the id of the remote list, you can check URL/lists.
547+ /// Example with curl:
548+ ///
549+ /// curl --anyauth -u admin:pass "http://localhost:9001/3.0/lists"
550+ ///
551+ /// If you're trying to import an entire list, create it first and then
552+ /// import its users with this command.
553+ ///
554+ /// Example:
555+ /// 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
556+ ImportMembers {
557+ #[arg(long)]
558+ /// REST HTTP endpoint e.g. http://localhost:9001/3.0/
559+ url: String,
560+ #[arg(long)]
561+ /// REST HTTP Basic Authentication username.
562+ username: String,
563+ #[arg(long)]
564+ /// REST HTTP Basic Authentication password.
565+ password: String,
566+ #[arg(long)]
567+ /// List ID of remote list to query.
568+ list_id: String,
569+ /// Show what would be inserted without performing any changes.
570+ #[arg(long)]
571+ dry_run: bool,
572+ /// Don't import list owners.
573+ #[arg(long)]
574+ skip_owners: bool,
575+ },
576+ }
577 diff --git a/cli/src/import.rs b/cli/src/import.rs
578new file mode 100644
579index 0000000..f7425dd
580--- /dev/null
581+++ b/cli/src/import.rs
582 @@ -0,0 +1,149 @@
583+ /*
584+ * This file is part of mailpot
585+ *
586+ * Copyright 2023 - Manos Pitsidianakis
587+ *
588+ * This program is free software: you can redistribute it and/or modify
589+ * it under the terms of the GNU Affero General Public License as
590+ * published by the Free Software Foundation, either version 3 of the
591+ * License, or (at your option) any later version.
592+ *
593+ * This program is distributed in the hope that it will be useful,
594+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
595+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
596+ * GNU Affero General Public License for more details.
597+ *
598+ * You should have received a copy of the GNU Affero General Public License
599+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
600+ */
601+
602+ use std::{borrow::Cow, time::Duration};
603+
604+ use base64::{engine::general_purpose, Engine as _};
605+ use mailpot::models::{ListOwner, ListSubscription};
606+ use ureq::Agent;
607+
608+ pub struct Mailman3Connection {
609+ agent: Agent,
610+ url: Cow<'static, str>,
611+ auth: String,
612+ }
613+
614+ impl Mailman3Connection {
615+ pub fn new(
616+ url: &str,
617+ username: &str,
618+ password: &str,
619+ ) -> Result<Self, Box<dyn std::error::Error>> {
620+ let agent: Agent = ureq::AgentBuilder::new()
621+ .timeout_read(Duration::from_secs(5))
622+ .timeout_write(Duration::from_secs(5))
623+ .build();
624+ let mut buf = String::new();
625+ general_purpose::STANDARD
626+ .encode_string(format!("{username}:{password}").as_bytes(), &mut buf);
627+
628+ let auth: String = format!("Basic {buf}");
629+
630+ Ok(Self {
631+ agent,
632+ url: url.trim_end_matches('/').to_string().into(),
633+ auth,
634+ })
635+ }
636+
637+ pub fn users(&self, list_address: &str) -> Result<Vec<Entry>, Box<dyn std::error::Error>> {
638+ let response: String = self
639+ .agent
640+ .get(&format!(
641+ "{}/lists/{list_address}/roster/member?fields=email&fields=display_name",
642+ self.url
643+ ))
644+ .set("Authorization", &self.auth)
645+ .call()?
646+ .into_string()?;
647+ Ok(serde_json::from_str::<Roster>(&response)?.entries)
648+ }
649+
650+ pub fn owners(&self, list_address: &str) -> Result<Vec<Entry>, Box<dyn std::error::Error>> {
651+ let response: String = self
652+ .agent
653+ .get(&format!(
654+ "{}/lists/{list_address}/roster/owner?fields=email&fields=display_name",
655+ self.url
656+ ))
657+ .set("Authorization", &self.auth)
658+ .call()?
659+ .into_string()?;
660+ Ok(serde_json::from_str::<Roster>(&response)?.entries)
661+ }
662+ }
663+
664+ #[derive(serde::Deserialize, Debug)]
665+ pub struct Roster {
666+ pub entries: Vec<Entry>,
667+ }
668+
669+ #[derive(serde::Deserialize, Debug)]
670+ pub struct Entry {
671+ display_name: String,
672+ email: String,
673+ }
674+
675+ impl Entry {
676+ pub fn display_name(&self) -> Option<&str> {
677+ if !self.display_name.trim().is_empty() && &self.display_name != "None" {
678+ Some(&self.display_name)
679+ } else {
680+ None
681+ }
682+ }
683+
684+ pub fn email(&self) -> &str {
685+ &self.email
686+ }
687+
688+ pub fn into_subscription(self, list: i64) -> ListSubscription {
689+ let Self {
690+ display_name,
691+ email,
692+ } = self;
693+
694+ ListSubscription {
695+ pk: -1,
696+ list,
697+ address: email,
698+ name: if !display_name.trim().is_empty() && &display_name != "None" {
699+ Some(display_name)
700+ } else {
701+ None
702+ },
703+ account: None,
704+ enabled: true,
705+ verified: true,
706+ digest: false,
707+ hide_address: false,
708+ receive_duplicates: false,
709+ receive_own_posts: false,
710+ receive_confirmation: false,
711+ }
712+ }
713+
714+ pub fn into_owner(self, list: i64) -> ListOwner {
715+ let Self {
716+ display_name,
717+ email,
718+ } = self;
719+
720+ ListOwner {
721+ pk: -1,
722+ list,
723+ address: email,
724+ name: if !display_name.trim().is_empty() && &display_name != "None" {
725+ Some(display_name)
726+ } else {
727+ None
728+ },
729+ }
730+ }
731+ }
732 diff --git a/cli/src/lib.rs b/cli/src/lib.rs
733index b9439d7..67aad61 100644
734--- a/cli/src/lib.rs
735+++ b/cli/src/lib.rs
736 @@ -17,448 +17,11 @@
737 * along with this program. If not, see <https://www.gnu.org/licenses/>.
738 */
739
740+ extern crate base64;
741+ extern crate ureq;
742 pub use std::path::PathBuf;
743
744+ mod args;
745+ pub mod import;
746+ pub use args::*;
747 pub use clap::{Args, CommandFactory, Parser, Subcommand};
748-
749- #[derive(Debug, Parser)]
750- #[command(
751- name = "mpot",
752- about = "mailing list manager",
753- long_about = "Tool for mailpot mailing list management.",
754- before_long_help = "GNU Affero version 3 or later <https://www.gnu.org/licenses/>",
755- author,
756- version
757- )]
758- pub struct Opt {
759- /// Print logs.
760- #[arg(short, long)]
761- pub debug: bool,
762- /// Configuration file to use.
763- #[arg(short, long, value_parser)]
764- pub config: Option<PathBuf>,
765- #[command(subcommand)]
766- pub cmd: Command,
767- /// Silence all output.
768- #[arg(short, long)]
769- pub quiet: bool,
770- /// Verbose mode (-v, -vv, -vvv, etc).
771- #[arg(short, long, action = clap::ArgAction::Count)]
772- pub verbose: u8,
773- /// Debug log timestamp (sec, ms, ns, none).
774- #[arg(short, long)]
775- pub ts: Option<stderrlog::Timestamp>,
776- }
777-
778- #[derive(Debug, Subcommand)]
779- pub enum Command {
780- /// Prints a sample config file to STDOUT.
781- ///
782- /// You can generate a new configuration file by writing the output to a
783- /// file, e.g: mpot sample-config --with-smtp > config.toml
784- SampleConfig {
785- /// Use an SMTP connection instead of a shell process.
786- #[arg(long)]
787- with_smtp: bool,
788- },
789- /// Dumps database data to STDOUT.
790- DumpDatabase,
791- /// Lists all registered mailing lists.
792- ListLists,
793- /// Mailing list management.
794- List {
795- /// Selects mailing list to operate on.
796- list_id: String,
797- #[command(subcommand)]
798- cmd: ListCommand,
799- },
800- /// Create new list.
801- CreateList {
802- /// List name.
803- #[arg(long)]
804- name: String,
805- /// List ID.
806- #[arg(long)]
807- id: String,
808- /// List e-mail address.
809- #[arg(long)]
810- address: String,
811- /// List description.
812- #[arg(long)]
813- description: Option<String>,
814- /// List archive URL.
815- #[arg(long)]
816- archive_url: Option<String>,
817- },
818- /// Post message from STDIN to list.
819- Post {
820- /// Show e-mail processing result without actually consuming it.
821- #[arg(long)]
822- dry_run: bool,
823- },
824- /// Flush outgoing e-mail queue.
825- FlushQueue {
826- /// Show e-mail processing result without actually consuming it.
827- #[arg(long)]
828- dry_run: bool,
829- },
830- /// Mail that has not been handled properly end up in the error queue.
831- ErrorQueue {
832- #[command(subcommand)]
833- cmd: ErrorQueueCommand,
834- },
835- /// Import a maildir folder into an existing list.
836- ImportMaildir {
837- /// List-ID or primary key value.
838- list_id: String,
839- /// Path to a maildir mailbox.
840- /// Must contain {cur, tmp, new} folders.
841- #[arg(long, value_parser)]
842- maildir_path: PathBuf,
843- },
844- /// Update postfix maps and master.cf (probably needs root permissions).
845- UpdatePostfixConfig {
846- #[arg(short = 'p', long)]
847- /// Override location of master.cf file (default:
848- /// /etc/postfix/master.cf)
849- master_cf: Option<PathBuf>,
850- #[clap(flatten)]
851- config: PostfixConfig,
852- },
853- /// Print postfix maps and master.cf entry to STDOUT.
854- ///
855- /// Map output should be added to transport_maps and local_recipient_maps
856- /// parameters in postfix's main.cf. It must be saved in a plain text
857- /// file. To make postfix be able to read them, the postmap application
858- /// must be executed with the path to the map file as its sole argument.
859- ///
860- /// postmap /path/to/mylist_maps
861- ///
862- /// postmap is usually distributed along with the other postfix binaries.
863- ///
864- /// The master.cf entry must be manually appended to the master.cf file. See <https://www.postfix.org/master.5.html>.
865- PrintPostfixConfig {
866- #[clap(flatten)]
867- config: PostfixConfig,
868- },
869- /// All Accounts.
870- Accounts,
871- /// Account info.
872- AccountInfo {
873- /// Account address.
874- address: String,
875- },
876- /// Add account.
877- AddAccount {
878- /// E-mail address.
879- #[arg(long)]
880- address: String,
881- /// SSH public key for authentication.
882- #[arg(long)]
883- password: String,
884- /// Name.
885- #[arg(long)]
886- name: Option<String>,
887- /// Public key.
888- #[arg(long)]
889- public_key: Option<String>,
890- #[arg(long)]
891- /// Is account enabled.
892- enabled: Option<bool>,
893- },
894- /// Remove account.
895- RemoveAccount {
896- #[arg(long)]
897- /// E-mail address.
898- address: String,
899- },
900- /// Update account info.
901- UpdateAccount {
902- /// Address to edit.
903- address: String,
904- /// Public key for authentication.
905- #[arg(long)]
906- password: Option<String>,
907- /// Name.
908- #[arg(long)]
909- name: Option<Option<String>>,
910- /// Public key.
911- #[arg(long)]
912- public_key: Option<Option<String>>,
913- #[arg(long)]
914- /// Is account enabled.
915- enabled: Option<Option<bool>>,
916- },
917- /// Show and fix possible data mistakes or inconsistencies.
918- Repair {
919- /// Fix errors (default: false)
920- #[arg(long, default_value = "false")]
921- fix: bool,
922- /// Select all tests (default: false)
923- #[arg(long, default_value = "false")]
924- all: bool,
925- /// Post `datetime` column must have the Date: header value, in RFC2822
926- /// format.
927- #[arg(long, default_value = "false")]
928- datetime_header_value: bool,
929- /// Remove accounts that have no matching subscriptions.
930- #[arg(long, default_value = "false")]
931- remove_empty_accounts: bool,
932- /// Remove subscription requests that have been accepted.
933- #[arg(long, default_value = "false")]
934- remove_accepted_subscription_requests: bool,
935- /// Warn if a list has no owners.
936- #[arg(long, default_value = "false")]
937- warn_list_no_owner: bool,
938- },
939- }
940-
941- /// Postfix config values.
942- #[derive(Debug, Args)]
943- pub struct PostfixConfig {
944- /// User that runs mailpot when postfix relays a message.
945- ///
946- /// Must not be the `postfix` user.
947- /// Must have permissions to access the database file and the data
948- /// directory.
949- #[arg(short, long)]
950- pub user: String,
951- /// Group that runs mailpot when postfix relays a message.
952- /// Optional.
953- #[arg(short, long)]
954- pub group: Option<String>,
955- /// The path to the mailpot binary postfix will execute.
956- #[arg(long)]
957- pub binary_path: PathBuf,
958- /// Limit the number of mailpot instances that can exist at the same time.
959- ///
960- /// Default is 1.
961- #[arg(long, default_value = "1")]
962- pub process_limit: Option<u64>,
963- /// The directory in which the map files are saved.
964- ///
965- /// Default is `data_path` from [`Configuration`](mailpot::Configuration).
966- #[arg(long)]
967- pub map_output_path: Option<PathBuf>,
968- /// The name of the postfix service name to use.
969- /// Default is `mailpot`.
970- ///
971- /// A postfix service is a daemon managed by the postfix process.
972- /// Each entry in the `master.cf` configuration file defines a single
973- /// service.
974- ///
975- /// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html):
976- /// <https://www.postfix.org/master.5.html>.
977- #[arg(long)]
978- pub transport_name: Option<String>,
979- }
980-
981- #[derive(Debug, Subcommand)]
982- pub enum ErrorQueueCommand {
983- /// List.
984- List,
985- /// Print entry in RFC5322 or JSON format.
986- Print {
987- /// index of entry.
988- #[arg(long)]
989- index: Vec<i64>,
990- },
991- /// Delete entry and print it in stdout.
992- Delete {
993- /// index of entry.
994- #[arg(long)]
995- index: Vec<i64>,
996- /// Do not print in stdout.
997- #[arg(long)]
998- quiet: bool,
999- },
1000- }
1001-
1002- /// Subscription options.
1003- #[derive(Debug, Args)]
1004- pub struct SubscriptionOptions {
1005- /// Name.
1006- #[arg(long)]
1007- pub name: Option<String>,
1008- /// Send messages as digest.
1009- #[arg(long, default_value = "false")]
1010- pub digest: Option<bool>,
1011- /// Hide message from list when posting.
1012- #[arg(long, default_value = "false")]
1013- pub hide_address: Option<bool>,
1014- /// Hide message from list when posting.
1015- #[arg(long, default_value = "false")]
1016- /// E-mail address verification status.
1017- pub verified: Option<bool>,
1018- #[arg(long, default_value = "true")]
1019- /// Receive confirmation email when posting.
1020- pub receive_confirmation: Option<bool>,
1021- #[arg(long, default_value = "true")]
1022- /// Receive posts from list even if address exists in To or Cc header.
1023- pub receive_duplicates: Option<bool>,
1024- #[arg(long, default_value = "false")]
1025- /// Receive own posts from list.
1026- pub receive_own_posts: Option<bool>,
1027- #[arg(long, default_value = "true")]
1028- /// Is subscription enabled.
1029- pub enabled: Option<bool>,
1030- }
1031-
1032- /// Account options.
1033- #[derive(Debug, Args)]
1034- pub struct AccountOptions {
1035- /// Name.
1036- #[arg(long)]
1037- pub name: Option<String>,
1038- /// Public key.
1039- #[arg(long)]
1040- pub public_key: Option<String>,
1041- #[arg(long)]
1042- /// Is account enabled.
1043- pub enabled: Option<bool>,
1044- }
1045-
1046- #[derive(Debug, Subcommand)]
1047- pub enum ListCommand {
1048- /// List subscriptions of list.
1049- Subscriptions,
1050- /// Add subscription to list.
1051- AddSubscription {
1052- /// E-mail address.
1053- #[arg(long)]
1054- address: String,
1055- #[clap(flatten)]
1056- subscription_options: SubscriptionOptions,
1057- },
1058- /// Remove subscription from list.
1059- RemoveSubscription {
1060- #[arg(long)]
1061- /// E-mail address.
1062- address: String,
1063- },
1064- /// Update subscription info.
1065- UpdateSubscription {
1066- /// Address to edit.
1067- address: String,
1068- #[clap(flatten)]
1069- subscription_options: SubscriptionOptions,
1070- },
1071- /// Add a new post policy.
1072- AddPolicy {
1073- #[arg(long)]
1074- /// Only list owners can post.
1075- announce_only: bool,
1076- #[arg(long)]
1077- /// Only subscriptions can post.
1078- subscription_only: bool,
1079- #[arg(long)]
1080- /// Subscriptions can post.
1081- /// Other posts must be approved by list owners.
1082- approval_needed: bool,
1083- #[arg(long)]
1084- /// Anyone can post without restrictions.
1085- open: bool,
1086- #[arg(long)]
1087- /// Allow posts, but handle it manually.
1088- custom: bool,
1089- },
1090- // Remove post policy.
1091- RemovePolicy {
1092- #[arg(long)]
1093- /// Post policy primary key.
1094- pk: i64,
1095- },
1096- /// Add subscription policy to list.
1097- AddSubscribePolicy {
1098- #[arg(long)]
1099- /// Send confirmation e-mail when subscription is finalized.
1100- send_confirmation: bool,
1101- #[arg(long)]
1102- /// Anyone can subscribe without restrictions.
1103- open: bool,
1104- #[arg(long)]
1105- /// Only list owners can manually add subscriptions.
1106- manual: bool,
1107- #[arg(long)]
1108- /// Anyone can request to subscribe.
1109- request: bool,
1110- #[arg(long)]
1111- /// Allow subscriptions, but handle it manually.
1112- custom: bool,
1113- },
1114- RemoveSubscribePolicy {
1115- #[arg(long)]
1116- /// Subscribe policy primary key.
1117- pk: i64,
1118- },
1119- /// Add list owner to list.
1120- AddListOwner {
1121- #[arg(long)]
1122- address: String,
1123- #[arg(long)]
1124- name: Option<String>,
1125- },
1126- RemoveListOwner {
1127- #[arg(long)]
1128- /// List owner primary key.
1129- pk: i64,
1130- },
1131- /// Alias for update-subscription --enabled true.
1132- EnableSubscription {
1133- /// Subscription address.
1134- address: String,
1135- },
1136- /// Alias for update-subscription --enabled false.
1137- DisableSubscription {
1138- /// Subscription address.
1139- address: String,
1140- },
1141- /// Update mailing list details.
1142- Update {
1143- /// New list name.
1144- #[arg(long)]
1145- name: Option<String>,
1146- /// New List-ID.
1147- #[arg(long)]
1148- id: Option<String>,
1149- /// New list address.
1150- #[arg(long)]
1151- address: Option<String>,
1152- /// New list description.
1153- #[arg(long)]
1154- description: Option<String>,
1155- /// New list archive URL.
1156- #[arg(long)]
1157- archive_url: Option<String>,
1158- /// New owner address local part.
1159- /// If empty, it defaults to '+owner'.
1160- #[arg(long)]
1161- owner_local_part: Option<String>,
1162- /// New request address local part.
1163- /// If empty, it defaults to '+request'.
1164- #[arg(long)]
1165- request_local_part: Option<String>,
1166- /// Require verification of e-mails for new subscriptions.
1167- ///
1168- /// Subscriptions that are initiated from the subscription's address are
1169- /// verified automatically.
1170- #[arg(long)]
1171- verify: Option<bool>,
1172- /// Public visibility of list.
1173- ///
1174- /// If hidden, the list will not show up in public APIs unless
1175- /// requests to it won't work.
1176- #[arg(long)]
1177- hidden: Option<bool>,
1178- /// Enable or disable the list's functionality.
1179- ///
1180- /// If not enabled, the list will continue to show up in the database
1181- /// but e-mails and requests to it won't work.
1182- #[arg(long)]
1183- enabled: Option<bool>,
1184- },
1185- /// Show mailing list health status.
1186- Health,
1187- /// Show mailing list info.
1188- Info,
1189- }
1190 diff --git a/cli/src/main.rs b/cli/src/main.rs
1191index 0a5c90a..dc9d80b 100644
1192--- a/cli/src/main.rs
1193+++ b/cli/src/main.rs
1194 @@ -421,6 +421,61 @@ fn run_app(opt: Opt) -> Result<()> {
1195 };
1196 db.update_list(changeset)?;
1197 }
1198+ ImportMembers {
1199+ url,
1200+ username,
1201+ password,
1202+ list_id,
1203+ dry_run,
1204+ skip_owners,
1205+ } => {
1206+ let conn = import::Mailman3Connection::new(&url, &username, &password).unwrap();
1207+ if dry_run {
1208+ let entries = conn.users(&list_id).unwrap();
1209+ println!("{} result(s)", entries.len());
1210+ for e in entries {
1211+ println!(
1212+ "{}{}<{}>",
1213+ if let Some(n) = e.display_name() {
1214+ n
1215+ } else {
1216+ ""
1217+ },
1218+ if e.display_name().is_none() { "" } else { " " },
1219+ e.email()
1220+ );
1221+ }
1222+ if !skip_owners {
1223+ let entries = conn.owners(&list_id).unwrap();
1224+ println!("\nOwners: {} result(s)", entries.len());
1225+ for e in entries {
1226+ println!(
1227+ "{}{}<{}>",
1228+ if let Some(n) = e.display_name() {
1229+ n
1230+ } else {
1231+ ""
1232+ },
1233+ if e.display_name().is_none() { "" } else { " " },
1234+ e.email()
1235+ );
1236+ }
1237+ }
1238+ } else {
1239+ let entries = conn.users(&list_id).unwrap();
1240+ let tx = db.transaction(Default::default()).unwrap();
1241+ for sub in entries.into_iter().map(|e| e.into_subscription(list.pk)) {
1242+ tx.add_subscription(list.pk, sub)?;
1243+ }
1244+ if !skip_owners {
1245+ let entries = conn.owners(&list_id).unwrap();
1246+ for sub in entries.into_iter().map(|e| e.into_owner(list.pk)) {
1247+ tx.add_list_owner(sub)?;
1248+ }
1249+ }
1250+ tx.commit()?;
1251+ }
1252+ }
1253 }
1254 }
1255 CreateList {
1256 diff --git a/core/src/connection.rs b/core/src/connection.rs
1257index 7d3e619..ff9f2b5 100644
1258--- a/core/src/connection.rs
1259+++ b/core/src/connection.rs
1260 @@ -670,4 +670,140 @@ impl Connection {
1261 tx.commit()?;
1262 Ok(())
1263 }
1264+
1265+ /// Execute operations inside an SQL transaction.
1266+ pub fn transaction(
1267+ &'_ self,
1268+ behavior: transaction::TransactionBehavior,
1269+ ) -> Result<transaction::Transaction<'_>> {
1270+ use transaction::*;
1271+
1272+ let query = match behavior {
1273+ TransactionBehavior::Deferred => "BEGIN DEFERRED",
1274+ TransactionBehavior::Immediate => "BEGIN IMMEDIATE",
1275+ TransactionBehavior::Exclusive => "BEGIN EXCLUSIVE",
1276+ };
1277+ self.connection.execute_batch(query)?;
1278+ Ok(Transaction {
1279+ conn: self,
1280+ drop_behavior: DropBehavior::Rollback,
1281+ })
1282+ }
1283+ }
1284+
1285+ /// Execute operations inside an SQL transaction.
1286+ pub mod transaction {
1287+ use super::*;
1288+
1289+ /// A transaction handle.
1290+ #[derive(Debug)]
1291+ pub struct Transaction<'conn> {
1292+ pub(super) conn: &'conn Connection,
1293+ pub(super) drop_behavior: DropBehavior,
1294+ }
1295+
1296+ impl Drop for Transaction<'_> {
1297+ fn drop(&mut self) {
1298+ _ = self.finish_();
1299+ }
1300+ }
1301+
1302+ impl Transaction<'_> {
1303+ /// Commit and consume transaction.
1304+ pub fn commit(mut self) -> Result<()> {
1305+ self.commit_()
1306+ }
1307+
1308+ fn commit_(&mut self) -> Result<()> {
1309+ self.conn.connection.execute_batch("COMMIT")?;
1310+ Ok(())
1311+ }
1312+
1313+ /// Configure the transaction to perform the specified action when it is
1314+ /// dropped.
1315+ #[inline]
1316+ pub fn set_drop_behavior(&mut self, drop_behavior: DropBehavior) {
1317+ self.drop_behavior = drop_behavior;
1318+ }
1319+
1320+ /// A convenience method which consumes and rolls back a transaction.
1321+ #[inline]
1322+ pub fn rollback(mut self) -> Result<()> {
1323+ self.rollback_()
1324+ }
1325+
1326+ fn rollback_(&mut self) -> Result<()> {
1327+ self.conn.connection.execute_batch("ROLLBACK")?;
1328+ Ok(())
1329+ }
1330+
1331+ /// Consumes the transaction, committing or rolling back according to
1332+ /// the current setting (see `drop_behavior`).
1333+ ///
1334+ /// Functionally equivalent to the `Drop` implementation, but allows
1335+ /// callers to see any errors that occur.
1336+ #[inline]
1337+ pub fn finish(mut self) -> Result<()> {
1338+ self.finish_()
1339+ }
1340+
1341+ #[inline]
1342+ fn finish_(&mut self) -> Result<()> {
1343+ if self.conn.connection.is_autocommit() {
1344+ return Ok(());
1345+ }
1346+ match self.drop_behavior {
1347+ DropBehavior::Commit => self.commit_().or_else(|_| self.rollback_()),
1348+ DropBehavior::Rollback => self.rollback_(),
1349+ DropBehavior::Ignore => Ok(()),
1350+ DropBehavior::Panic => panic!("Transaction dropped unexpectedly."),
1351+ }
1352+ }
1353+ }
1354+
1355+ impl std::ops::Deref for Transaction<'_> {
1356+ type Target = Connection;
1357+
1358+ #[inline]
1359+ fn deref(&self) -> &Connection {
1360+ self.conn
1361+ }
1362+ }
1363+
1364+ /// Options for transaction behavior. See [BEGIN
1365+ /// TRANSACTION](http://www.sqlite.org/lang_transaction.html) for details.
1366+ #[derive(Copy, Clone, Default)]
1367+ #[non_exhaustive]
1368+ pub enum TransactionBehavior {
1369+ /// DEFERRED means that the transaction does not actually start until
1370+ /// the database is first accessed.
1371+ Deferred,
1372+ /// IMMEDIATE cause the database connection to start a new write
1373+ /// immediately, without waiting for a writes statement.
1374+ Immediate,
1375+ #[default]
1376+ /// EXCLUSIVE prevents other database connections from reading the
1377+ /// database while the transaction is underway.
1378+ Exclusive,
1379+ }
1380+
1381+ /// Options for how a Transaction or Savepoint should behave when it is
1382+ /// dropped.
1383+ #[derive(Default, Copy, Clone, Debug, PartialEq, Eq)]
1384+ #[non_exhaustive]
1385+ pub enum DropBehavior {
1386+ #[default]
1387+ /// Roll back the changes. This is the default.
1388+ Rollback,
1389+
1390+ /// Commit the changes.
1391+ Commit,
1392+
1393+ /// Do not commit or roll back changes - this will leave the transaction
1394+ /// or savepoint open, so should be used with care.
1395+ Ignore,
1396+
1397+ /// Panic. Used to enforce intentional behavior during development.
1398+ Panic,
1399+ }
1400 }
1401 diff --git a/docs/mpot.1 b/docs/mpot.1
1402index 888ba26..02a0504 100644
1403--- a/docs/mpot.1
1404+++ b/docs/mpot.1
1405 @@ -569,6 +569,37 @@ Show mailing list info.
1406 .ie \n(.g .ds Aq \(aq
1407 .el .ds Aq '
1408 .\fB
1409+ .SS mpot list import-members
1410+ .\fR
1411+ .br
1412+
1413+ .br
1414+
1415+ 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]
1416+ .br
1417+
1418+ Import members in a local list from a remote mailman3 REST API instance.
1419+ .TP
1420+ \-\-url \fIURL\fR
1421+ REST HTTP endpoint e.g. http://localhost:9001/3.0/.
1422+ .TP
1423+ \-\-username \fIUSERNAME\fR
1424+ REST HTTP Basic Authentication username.
1425+ .TP
1426+ \-\-password \fIPASSWORD\fR
1427+ REST HTTP Basic Authentication password.
1428+ .TP
1429+ \-\-list\-id \fILIST_ID\fR
1430+ List ID of remote list to query.
1431+ .TP
1432+ \-\-dry\-run
1433+ Show what would be inserted without performing any changes.
1434+ .TP
1435+ \-\-skip\-owners
1436+ Don\*(Aqt import list owners.
1437+ .ie \n(.g .ds Aq \(aq
1438+ .el .ds Aq '
1439+ .\fB
1440 .SS mpot create-list
1441 .\fR
1442 .br