Compare commits

..

2 Commits
main ... wip

Author SHA1 Message Date
Manos Pitsidianakis fb1d9ce2ee
wip2 2023-09-25 18:21:51 +03:00
Manos Pitsidianakis 1d1d6d7a17
wip1 2023-09-25 18:21:44 +03:00
149 changed files with 2219 additions and 4169 deletions

View File

@ -3,7 +3,7 @@ name: Build release binary
env:
RUST_BACKTRACE: 1
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SQLITE_BIN: /home/runner/.sqlite3/sqlite3
SQLITE_BIN: ~/.sqlite3/sqlite3
on:
workflow_dispatch:
@ -31,15 +31,15 @@ jobs:
name: Cache sqlite3 binary
uses: actions/cache@v3
with:
path: /home/runner/.sqlite3
path: ~/.sqlite3
key: toolchain-sqlite3
- if: ${{ steps.cache-sqlite3-bin.outputs.cache-hit != 'true' }}
name: Download sqlite3 binary
run: |
set -ex
sudo apt-get install -y --quiet wget unzip
mkdir -p /home/runner/.sqlite3
cd /home/runner/.sqlite3
mkdir -p ~/.sqlite3
cd ~/.sqlite3
wget "https://sqlite.org/2023/sqlite-tools-linux-x86-3420000.zip"
unzip sqlite-tools-linux-x86-3420000.zip
mv sqlite-tools-linux-x86-3420000/* .

View File

@ -3,7 +3,7 @@ name: Code coverage
env:
RUST_BACKTRACE: 1
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SQLITE_BIN: /home/runner/.sqlite3/sqlite3
SQLITE_BIN: ~/.sqlite3/sqlite3
on:
workflow_dispatch:
@ -21,15 +21,15 @@ jobs:
name: Cache sqlite3 binary
uses: actions/cache@v3
with:
path: /home/runner/.sqlite3
path: ~/.sqlite3
key: toolchain-sqlite3
- if: ${{ steps.cache-sqlite3-bin.outputs.cache-hit != 'true' }}
name: Download sqlite3 binary
run: |
set -ex
sudo apt-get install -y --quiet wget unzip
mkdir -p /home/runner/.sqlite3
cd /home/runner/.sqlite3
mkdir -p ~/.sqlite3
cd ~/.sqlite3
wget "https://sqlite.org/2023/sqlite-tools-linux-x86-3420000.zip"
unzip sqlite-tools-linux-x86-3420000.zip
mv sqlite-tools-linux-x86-3420000/* .

View File

@ -3,7 +3,7 @@ name: Build rustdoc for Github Pages
env:
RUST_BACKTRACE: 1
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SQLITE_BIN: /home/runner/.sqlite3/sqlite3
SQLITE_BIN: ~/.sqlite3/sqlite3
on:
workflow_dispatch:
@ -17,15 +17,15 @@ jobs:
name: Cache sqlite3 binary
uses: actions/cache@v3
with:
path: /home/runner/.sqlite3
path: ~/.sqlite3
key: toolchain-sqlite3
- if: ${{ steps.cache-sqlite3-bin.outputs.cache-hit != 'true' }}
name: Download sqlite3 binary
run: |
set -ex
sudo apt-get install -y --quiet wget unzip
mkdir -p /home/runner/.sqlite3
cd /home/runner/.sqlite3
mkdir -p ~/.sqlite3
cd ~/.sqlite3
wget "https://sqlite.org/2023/sqlite-tools-linux-x86-3420000.zip"
unzip sqlite-tools-linux-x86-3420000.zip
mv sqlite-tools-linux-x86-3420000/* .

View File

@ -3,7 +3,7 @@ name: Tests
env:
RUST_BACKTRACE: 1
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
SQLITE_BIN: /home/runner/.sqlite3/sqlite3
SQLITE_BIN: ~/.sqlite3/sqlite3
on:
workflow_dispatch:
@ -35,15 +35,15 @@ jobs:
name: Cache sqlite3 binary
uses: actions/cache@v3
with:
path: /home/runner/.sqlite3
path: ~/.sqlite3
key: toolchain-sqlite3
- if: ${{ steps.cache-sqlite3-bin.outputs.cache-hit != 'true' }}
name: Download sqlite3 binary
run: |
set -ex
sudo apt-get install -y --quiet wget unzip
mkdir -p /home/runner/.sqlite3
cd /home/runner/.sqlite3
mkdir -p ~/.sqlite3
cd ~/.sqlite3
wget "https://sqlite.org/2023/sqlite-tools-linux-x86-3420000.zip"
unzip sqlite-tools-linux-x86-3420000.zip
mv sqlite-tools-linux-x86-3420000/* .

1584
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,12 @@
[workspace]
resolver = "2"
members = [
"mailpot",
"mailpot-archives",
"mailpot-cli",
"mailpot-http",
"archive-http",
"cli",
"core",
"mailpot-tests",
"mailpot-web",
"rest-http",
"web",
]
[profile.release]

View File

@ -6,7 +6,7 @@ DJHTMLBIN = djhtml
BLACKBIN = black
PRINTF = /usr/bin/printf
HTML_FILES := $(shell find mailpot-web/src/templates -type f -print0 | tr '\0' ' ')
HTML_FILES := $(shell find web/src/templates -type f -print0 | tr '\0' ' ')
PY_FILES := $(shell find . -type f -name '*.py' -print0 | tr '\0' ' ')
.PHONY: check

View File

@ -47,8 +47,8 @@ $ mkdir -p /home/user/.config/mailpot
$ export MPOT_CONFIG=/home/user/.config/mailpot/config.toml
$ cargo run --bin mpot -- sample-config > "$MPOT_CONFIG"
$ # edit config and set database path e.g. "/home/user/.local/share/mailpot/mpot.db"
$ cargo run --bin mpot -- -c "$MPOT_CONFIG" list-lists
No lists found.
$ cargo run --bin mpot -- -c "$MPOT_CONFIG" db-location
/home/user/.local/share/mailpot/mpot.db
```
This creates the database file in the configuration file as if you executed the following:
@ -61,73 +61,27 @@ $ sqlite3 /home/user/.local/share/mailpot/mpot.db < ./core/src/schema.sql
```text
% mpot help
GNU Affero version 3 or later <https://www.gnu.org/licenses/>
mailpot 0.1.0
mini mailing list manager
Tool for mailpot mailing list management.
USAGE:
mpot [FLAGS] [OPTIONS] <SUBCOMMAND>
Usage: mpot [OPTIONS] <COMMAND>
FLAGS:
-d, --debug Activate debug mode
-h, --help Prints help information
-V, --version Prints version information
Commands:
sample-config
Prints a sample config file to STDOUT
dump-database
Dumps database data to STDOUT
list-lists
Lists all registered mailing lists
list
Mailing list management
create-list
Create new list
post
Post message from STDIN to list
flush-queue
Flush outgoing e-mail queue
error-queue
Mail that has not been handled properly end up in the error queue
queue
Mail that has not been handled properly end up in the error queue
import-maildir
Import a maildir folder into an existing list
update-postfix-config
Update postfix maps and master.cf (probably needs root permissions)
print-postfix-config
Print postfix maps and master.cf entry to STDOUT
accounts
All Accounts
account-info
Account info
add-account
Add account
remove-account
Remove account
update-account
Update account info
repair
Show and fix possible data mistakes or inconsistencies
help
Print this message or the help of the given subcommand(s)
OPTIONS:
-c, --config <config> Set config file
Options:
-d, --debug
Print logs
-c, --config <CONFIG>
Configuration file to use
-q, --quiet
Silence all output
-v, --verbose...
Verbose mode (-v, -vv, -vvv, etc)
-t, --ts <TS>
Debug log timestamp (sec, ms, ns, none)
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
SUBCOMMANDS:
create-list Create new list
db-location Prints database filesystem location
help Prints this message or the help of the given subcommand(s)
list Mailing list management
list-lists Lists all registered mailing lists
post Post message from STDIN to list
```
### Receiving mail

View File

@ -18,7 +18,7 @@ path = "src/main.rs"
[dependencies]
chrono = { version = "^0.4" }
lazy_static = "^1.4"
mailpot = { version = "^0.1", path = "../mailpot" }
mailpot = { version = "^0.1", path = "../core" }
minijinja = { version = "0.31.0", features = ["source", ] }
percent-encoding = { version = "^2.1", optional = true }
serde = { version = "^1", features = ["derive", ] }

View File

@ -38,8 +38,8 @@ fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
return Err("Output path is not a directory.".into());
}
std::fs::create_dir_all(output_path.join("lists"))?;
std::fs::create_dir_all(output_path.join("list"))?;
std::fs::create_dir_all(&output_path.join("lists"))?;
std::fs::create_dir_all(&output_path.join("list"))?;
let conf = Configuration::from_file(config_path)
.map_err(|err| format!("Could not load config {config_path}: {err}"))?;
@ -67,7 +67,7 @@ fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
.write(true)
.create(true)
.truncate(true)
.open(output_path.join("index.html"))?;
.open(&output_path.join("index.html"))?;
let crumbs = vec![Crumb {
label: "Lists".into(),
url: format!("{root_url_prefix}/").into(),

View File

@ -20,20 +20,20 @@ doc-scrape-examples = true
base64 = { version = "0.21" }
clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "help", "usage", "error-context", "suggestions"] }
log = "0.4"
mailpot = { version = "^0.1", path = "../mailpot" }
mailpot = { version = "^0.1", path = "../core" }
serde = { version = "^1", features = ["derive", ] }
serde_json = "^1"
stderrlog = { version = "^0.6" }
stderrlog = "^0.5"
ureq = { version = "2.6", default-features = false }
[dev-dependencies]
assert_cmd = "2"
mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
predicates = "3"
tempfile = { version = "3.9" }
tempfile = "3.3"
[build-dependencies]
clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "wrap_help", "help", "usage", "error-context", "suggestions"] }
clap_mangen = "0.2.10"
mailpot = { version = "^0.1", path = "../mailpot" }
stderrlog = { version = "^0.6" }
mailpot = { version = "^0.1", path = "../core" }
stderrlog = "^0.5"

View File

@ -23,7 +23,7 @@ use std::{
io::Write,
};
use clap::{ArgAction, CommandFactory};
use clap::ArgAction;
use clap_mangen::{roff, Man};
use roff::{bold, italic, roman, Inline, Roff};

View File

@ -19,7 +19,7 @@
pub use std::path::PathBuf;
pub use clap::{builder::TypedValueParser, Args, Parser, Subcommand};
pub use clap::{builder::TypedValueParser, Args, CommandFactory, Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
@ -102,7 +102,12 @@ pub enum Command {
#[arg(long)]
dry_run: bool,
},
/// Processed mail is stored in queues.
/// Mail that has not been handled properly end up in the error queue.
ErrorQueue {
#[command(subcommand)]
cmd: QueueCommand,
},
/// Mail that has not been handled properly end up in the error queue.
Queue {
#[arg(long, value_parser = QueueValueParser)]
queue: mailpot::queue::Queue,
@ -270,6 +275,9 @@ pub enum QueueCommand {
/// index of entry.
#[arg(long)]
index: Vec<i64>,
/// Do not print in stdout.
#[arg(long)]
quiet: bool,
},
}
@ -321,8 +329,6 @@ pub struct AccountOptions {
pub enum ListCommand {
/// List subscriptions of list.
Subscriptions,
/// List subscription requests.
SubscriptionRequests,
/// Add subscription to list.
AddSubscription {
/// E-mail address.
@ -344,19 +350,6 @@ pub enum ListCommand {
#[clap(flatten)]
subscription_options: SubscriptionOptions,
},
/// Accept a subscription request by its primary key.
AcceptSubscriptionRequest {
/// The primary key of the request.
pk: i64,
/// Do not send confirmation e-mail.
#[arg(long, default_value = "false")]
do_not_send_confirmation: bool,
},
/// Send subscription confirmation manually.
SendConfirmationForSubscription {
/// The primary key of the subscription.
pk: i64,
},
/// Add a new post policy.
AddPostPolicy {
#[arg(long)]
@ -554,14 +547,6 @@ impl TypedValueParser for QueueValueParser {
})?)
.map_err(|err| cmd.clone().error(ErrorKind::InvalidValue, err))
}
fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue>>> {
Some(Box::new(
mailpot::queue::Queue::possible_values()
.iter()
.map(clap::builder::PossibleValue::new),
))
}
}
impl Default for QueueValueParser {

View File

@ -22,8 +22,6 @@ extern crate ureq;
pub use std::path::PathBuf;
mod args;
pub mod commands;
pub mod import;
pub mod lints;
pub use args::*;
pub use clap::{Args, CommandFactory, Parser, Subcommand};

View File

@ -17,12 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use mailpot::{
chrono,
melib::{self, Envelope},
models::{Account, DbVal, ListSubscription, MailingList},
rusqlite, Connection, Result,
};
use super::*;
pub fn datetime_header_value_lint(db: &mut Connection, dry_run: bool) -> Result<()> {
let mut col = vec![];
@ -31,7 +26,7 @@ pub fn datetime_header_value_lint(db: &mut Connection, dry_run: bool) -> Result<
let iter = stmt.query_map([], |row| {
let pk: i64 = row.get("pk")?;
let date_s: String = row.get("datetime")?;
match melib::utils::datetime::rfc822_to_timestamp(date_s.trim()) {
match melib::datetime::rfc822_to_timestamp(date_s.trim()) {
Err(_) | Ok(0) => {
let mut timestamp: i64 = row.get("timestamp")?;
let created: i64 = row.get("created")?;
@ -75,11 +70,7 @@ pub fn datetime_header_value_lint(db: &mut Connection, dry_run: bool) -> Result<
{
v.to_rfc2822()
} else if let Some(v) = timestamp.map(|t| {
melib::utils::datetime::timestamp_to_string(
t,
Some(melib::utils::datetime::formats::RFC822_DATE),
true,
)
melib::datetime::timestamp_to_string(t, Some(melib::datetime::RFC822_DATE), true)
}) {
v
} else if let Ok(v) =

976
cli/src/main.rs 100644
View File

@ -0,0 +1,976 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
io::{Read, Write},
process::Stdio,
};
mod lints;
use lints::*;
use mailpot::{
melib::{backends::maildir::MaildirPathTrait, smol, smtp::*, Envelope, EnvelopeHash},
models::{changesets::*, *},
queue::QueueEntry,
transaction::TransactionBehavior,
Configuration, Connection, Error, ErrorKind, Result, *,
};
use mailpot_cli::*;
macro_rules! list {
($db:ident, $list_id:expr) => {{
$db.list_by_id(&$list_id)?.or_else(|| {
$list_id
.parse::<i64>()
.ok()
.map(|pk| $db.list(pk).ok())
.flatten()
.flatten()
})
}};
}
macro_rules! string_opts {
($field:ident) => {
if $field.as_deref().map(str::is_empty).unwrap_or(false) {
None
} else {
Some($field)
}
};
}
fn run_app(opt: Opt) -> Result<()> {
if opt.debug {
println!("DEBUG: {:?}", &opt);
}
if let Command::SampleConfig { with_smtp } = opt.cmd {
let mut new = Configuration::new("/path/to/sqlite.db");
new.administrators.push("admin@example.com".to_string());
if with_smtp {
new.send_mail = mailpot::SendMail::Smtp(SmtpServerConf {
hostname: "mail.example.com".to_string(),
port: 587,
envelope_from: "".to_string(),
auth: SmtpAuth::Auto {
username: "user".to_string(),
password: Password::Raw("hunter2".to_string()),
auth_type: SmtpAuthType::default(),
require_auth: true,
},
security: SmtpSecurity::StartTLS {
danger_accept_invalid_certs: false,
},
extensions: Default::default(),
});
}
println!("{}", new.to_toml());
return Ok(());
};
let config_path = if let Some(path) = opt.config.as_ref() {
path.as_path()
} else {
let mut opt = Opt::command();
opt.error(
clap::error::ErrorKind::MissingRequiredArgument,
"--config is required for mailing list operations",
)
.exit();
};
let config = Configuration::from_file(config_path)?;
use Command::*;
let mut db = Connection::open_or_create_db(config)?.trusted();
match opt.cmd {
SampleConfig { .. } => {}
DumpDatabase => {
let lists = db.lists()?;
let mut stdout = std::io::stdout();
serde_json::to_writer_pretty(&mut stdout, &lists)?;
for l in &lists {
serde_json::to_writer_pretty(&mut stdout, &db.list_subscriptions(l.pk)?)?;
}
}
ListLists => {
let lists = db.lists()?;
if lists.is_empty() {
println!("No lists found.");
} else {
for l in lists {
println!("- {} {:?}", l.id, l);
let list_owners = db.list_owners(l.pk)?;
if list_owners.is_empty() {
println!("\tList owners: None");
} else {
println!("\tList owners:");
for o in list_owners {
println!("\t- {}", o);
}
}
if let Some(s) = db.list_post_policy(l.pk)? {
println!("\tPost policy: {}", s);
} else {
println!("\tPost policy: None");
}
if let Some(s) = db.list_subscription_policy(l.pk)? {
println!("\tSubscription policy: {}", s);
} else {
println!("\tSubscription policy: None");
}
println!();
}
}
}
List { list_id, cmd } => {
let list = match list!(db, list_id) {
Some(v) => v,
None => {
return Err(format!("No list with id or pk {} was found", list_id).into());
}
};
use ListCommand::*;
match cmd {
Subscriptions => {
let subscriptions = db.list_subscriptions(list.pk)?;
if subscriptions.is_empty() {
println!("No subscriptions found.");
} else {
println!("Subscriptions of list {}", list.id);
for l in subscriptions {
println!("- {}", &l);
}
}
}
AddSubscription {
address,
subscription_options:
SubscriptionOptions {
name,
digest,
hide_address,
receive_duplicates,
receive_own_posts,
receive_confirmation,
enabled,
verified,
},
} => {
db.add_subscription(
list.pk,
ListSubscription {
pk: 0,
list: list.pk,
address,
account: None,
name,
digest: digest.unwrap_or(false),
hide_address: hide_address.unwrap_or(false),
receive_confirmation: receive_confirmation.unwrap_or(true),
receive_duplicates: receive_duplicates.unwrap_or(true),
receive_own_posts: receive_own_posts.unwrap_or(false),
enabled: enabled.unwrap_or(true),
verified: verified.unwrap_or(false),
},
)?;
}
RemoveSubscription { address } => {
let mut input = String::new();
loop {
println!(
"Are you sure you want to remove subscription of {} from list {}? \
[Yy/n]",
address, list
);
input.clear();
std::io::stdin().read_line(&mut input)?;
if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
break;
} else if input.trim() == "n" {
return Ok(());
}
}
db.remove_subscription(list.pk, &address)?;
}
Health => {
println!("{} health:", list);
let list_owners = db.list_owners(list.pk)?;
let post_policy = db.list_post_policy(list.pk)?;
let subscription_policy = db.list_subscription_policy(list.pk)?;
if list_owners.is_empty() {
println!("\tList has no owners: you should add at least one.");
} else {
for owner in list_owners {
println!("\tList owner: {}.", owner);
}
}
if let Some(p) = post_policy {
println!("\tList has post policy: {p}.");
} else {
println!("\tList has no post policy: you should add one.");
}
if let Some(p) = subscription_policy {
println!("\tList has subscription policy: {p}.");
} else {
println!("\tList has no subscription policy: you should add one.");
}
}
Info => {
println!("{} info:", list);
let list_owners = db.list_owners(list.pk)?;
let post_policy = db.list_post_policy(list.pk)?;
let subscription_policy = db.list_subscription_policy(list.pk)?;
let subscriptions = db.list_subscriptions(list.pk)?;
if subscriptions.is_empty() {
println!("No subscriptions.");
} else if subscriptions.len() == 1 {
println!("1 subscription.");
} else {
println!("{} subscriptions.", subscriptions.len());
}
if list_owners.is_empty() {
println!("List owners: None");
} else {
println!("List owners:");
for o in list_owners {
println!("\t- {}", o);
}
}
if let Some(s) = post_policy {
println!("Post policy: {s}");
} else {
println!("Post policy: None");
}
if let Some(s) = subscription_policy {
println!("Subscription policy: {s}");
} else {
println!("Subscription policy: None");
}
}
UpdateSubscription {
address,
subscription_options:
SubscriptionOptions {
name,
digest,
hide_address,
receive_duplicates,
receive_own_posts,
receive_confirmation,
enabled,
verified,
},
} => {
let name = if name
.as_ref()
.map(|s: &String| s.is_empty())
.unwrap_or(false)
{
None
} else {
Some(name)
};
let changeset = ListSubscriptionChangeset {
list: list.pk,
address,
account: None,
name,
digest,
verified,
hide_address,
receive_duplicates,
receive_own_posts,
receive_confirmation,
enabled,
};
db.update_subscription(changeset)?;
}
AddPostPolicy {
announce_only,
subscription_only,
approval_needed,
open,
custom,
} => {
let policy = PostPolicy {
pk: 0,
list: list.pk,
announce_only,
subscription_only,
approval_needed,
open,
custom,
};
let new_val = db.set_list_post_policy(policy)?;
println!("Added new policy with pk = {}", new_val.pk());
}
RemovePostPolicy { pk } => {
db.remove_list_post_policy(list.pk, pk)?;
println!("Removed policy with pk = {}", pk);
}
AddSubscriptionPolicy {
send_confirmation,
open,
manual,
request,
custom,
} => {
let policy = SubscriptionPolicy {
pk: 0,
list: list.pk,
send_confirmation,
open,
manual,
request,
custom,
};
let new_val = db.set_list_subscription_policy(policy)?;
println!("Added new subscribe policy with pk = {}", new_val.pk());
}
RemoveSubscriptionPolicy { pk } => {
db.remove_list_subscription_policy(list.pk, pk)?;
println!("Removed subscribe policy with pk = {}", pk);
}
AddListOwner { address, name } => {
let list_owner = ListOwner {
pk: 0,
list: list.pk,
address,
name,
};
let new_val = db.add_list_owner(list_owner)?;
println!("Added new list owner {}", new_val);
}
RemoveListOwner { pk } => {
db.remove_list_owner(list.pk, pk)?;
println!("Removed list owner with pk = {}", pk);
}
EnableSubscription { address } => {
let changeset = ListSubscriptionChangeset {
list: list.pk,
address,
account: None,
name: None,
digest: None,
verified: None,
enabled: Some(true),
hide_address: None,
receive_duplicates: None,
receive_own_posts: None,
receive_confirmation: None,
};
db.update_subscription(changeset)?;
}
DisableSubscription { address } => {
let changeset = ListSubscriptionChangeset {
list: list.pk,
address,
account: None,
name: None,
digest: None,
enabled: Some(false),
verified: None,
hide_address: None,
receive_duplicates: None,
receive_own_posts: None,
receive_confirmation: None,
};
db.update_subscription(changeset)?;
}
Update {
name,
id,
address,
description,
archive_url,
owner_local_part,
request_local_part,
verify,
hidden,
enabled,
} => {
let description = string_opts!(description);
let archive_url = string_opts!(archive_url);
let owner_local_part = string_opts!(owner_local_part);
let request_local_part = string_opts!(request_local_part);
let changeset = MailingListChangeset {
pk: list.pk,
name,
id,
address,
description,
archive_url,
owner_local_part,
request_local_part,
verify,
hidden,
enabled,
};
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 {
name,
id,
address,
description,
archive_url,
} => {
let new = db.create_list(MailingList {
pk: 0,
name,
id,
description,
topics: vec![],
address,
archive_url,
})?;
log::trace!("created new list {:#?}", new);
if !opt.quiet {
println!(
"Created new list {:?} with primary key {}",
new.id,
new.pk()
);
}
}
Post { dry_run } => {
if opt.debug {
println!("post dry_run{:?}", dry_run);
}
let tx = db.transaction(TransactionBehavior::Exclusive).unwrap();
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
match Envelope::from_bytes(input.as_bytes(), None) {
Ok(env) => {
if opt.debug {
eprintln!("{:?}", &env);
}
tx.post(&env, input.as_bytes(), dry_run)?;
}
Err(err) if input.trim().is_empty() => {
eprintln!("Empty input, abort.");
return Err(err.into());
}
Err(err) => {
eprintln!("Could not parse message: {}", err);
let p = tx.conf().save_message(input)?;
eprintln!("Message saved at {}", p.display());
return Err(err.into());
}
}
tx.commit()?;
}
FlushQueue { dry_run } => {
let tx = db.transaction(TransactionBehavior::Exclusive).unwrap();
let messages = if opt.debug {
println!("flush-queue dry_run {:?}", dry_run);
tx.queue(mailpot::queue::Queue::Out)?
.into_iter()
.map(DbVal::into_inner)
.chain(
tx.queue(mailpot::queue::Queue::Deferred)?
.into_iter()
.map(DbVal::into_inner),
)
.collect()
} else {
tx.delete_from_queue(mailpot::queue::Queue::Out, vec![])?
};
if opt.verbose > 0 || opt.debug {
println!("Queue out has {} messages.", messages.len());
}
let mut failures = Vec::with_capacity(messages.len());
let send_mail = tx.conf().send_mail.clone();
match send_mail {
mailpot::SendMail::ShellCommand(cmd) => {
fn submit(cmd: &str, msg: &QueueEntry) -> Result<()> {
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.stdout(Stdio::piped())
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("sh command failed to start")?;
let mut stdin = child.stdin.take().context("Failed to open stdin")?;
let builder = std::thread::Builder::new();
std::thread::scope(|s| {
let handler = builder
.spawn_scoped(s, move || {
stdin
.write_all(&msg.message)
.expect("Failed to write to stdin");
})
.context(
"Could not spawn IPC communication thread for SMTP \
ShellCommand process",
)?;
handler.join().map_err(|_| {
ErrorKind::External(mailpot::anyhow::anyhow!(
"Could not join with IPC communication thread for SMTP \
ShellCommand process"
))
})?;
Ok::<(), Error>(())
})?;
Ok(())
}
for msg in messages {
if let Err(err) = submit(&cmd, &msg) {
failures.push((err, msg));
}
}
}
mailpot::SendMail::Smtp(_) => {
let conn_future = tx.new_smtp_connection()?;
failures = smol::future::block_on(smol::spawn(async move {
let mut conn = conn_future.await?;
for msg in messages {
if let Err(err) = Connection::submit(&mut conn, &msg, dry_run).await {
failures.push((err, msg));
}
}
Ok::<_, Error>(failures)
}))?;
}
}
for (err, mut msg) in failures {
log::error!("Message {msg:?} failed with: {err}. Inserting to Deferred queue.");
msg.queue = mailpot::queue::Queue::Deferred;
tx.insert_to_queue(msg)?;
}
tx.commit()?;
}
ErrorQueue { cmd } => match cmd {
QueueCommand::List => {
let errors = db.queue(mailpot::queue::Queue::Error)?;
if errors.is_empty() {
println!("Error queue is empty.");
} else {
for e in errors {
println!(
"- {} {} {} {} {}",
e.pk, e.datetime, e.from_address, e.to_addresses, e.subject
);
}
}
}
QueueCommand::Print { index } => {
let mut errors = db.queue(mailpot::queue::Queue::Error)?;
if !index.is_empty() {
errors.retain(|el| index.contains(&el.pk()));
}
if errors.is_empty() {
println!("Error queue is empty.");
} else {
for e in errors {
println!("{e:?}");
}
}
}
QueueCommand::Delete { index, quiet } => {
let mut errors = db.queue(mailpot::queue::Queue::Error)?;
if !index.is_empty() {
errors.retain(|el| index.contains(&el.pk()));
}
if errors.is_empty() {
if !quiet {
println!("Error queue is empty.");
}
} else {
if !quiet {
println!("Deleting error queue elements {:?}", &index);
}
db.delete_from_queue(mailpot::queue::Queue::Error, index)?;
if !quiet {
for e in errors {
println!("{e:?}");
}
}
}
}
},
Queue { queue, cmd } => match cmd {
QueueCommand::List => {
let entries = db.queue(queue)?;
if entries.is_empty() {
println!("Queue {queue} is empty.");
} else {
for e in entries {
println!(
"- {} {} {} {} {}",
e.pk, e.datetime, e.from_address, e.to_addresses, e.subject
);
}
}
}
QueueCommand::Print { index } => {
let mut entries = db.queue(queue)?;
if !index.is_empty() {
entries.retain(|el| index.contains(&el.pk()));
}
if entries.is_empty() {
println!("Queue {queue} is empty.");
} else {
for e in entries {
println!("{e:?}");
}
}
}
QueueCommand::Delete { index, quiet } => {
let mut entries = db.queue(queue)?;
if !index.is_empty() {
entries.retain(|el| index.contains(&el.pk()));
}
if entries.is_empty() {
if !quiet {
println!("Queue {queue} is empty.");
}
} else {
if !quiet {
println!("Deleting queue {queue} elements {:?}", &index);
}
db.delete_from_queue(queue, index)?;
if !quiet {
for e in entries {
println!("{e:?}");
}
}
}
}
},
ImportMaildir {
list_id,
mut maildir_path,
} => {
let list = match list!(db, list_id) {
Some(v) => v,
None => {
return Err(format!("No list with id or pk {} was found", list_id).into());
}
};
if !maildir_path.is_absolute() {
maildir_path = std::env::current_dir()
.expect("could not detect current directory")
.join(&maildir_path);
}
fn get_file_hash(file: &std::path::Path) -> EnvelopeHash {
let mut hasher = DefaultHasher::default();
file.hash(&mut hasher);
EnvelopeHash(hasher.finish())
}
let mut buf = Vec::with_capacity(4096);
let files =
melib::backends::maildir::MaildirType::list_mail_in_maildir_fs(maildir_path, true)?;
let mut ctr = 0;
for file in files {
let hash = get_file_hash(&file);
let mut reader = std::io::BufReader::new(std::fs::File::open(&file)?);
buf.clear();
reader.read_to_end(&mut buf)?;
if let Ok(mut env) = Envelope::from_bytes(buf.as_slice(), Some(file.flags())) {
env.set_hash(hash);
db.insert_post(list.pk, &buf, &env)?;
ctr += 1;
}
}
println!("Inserted {} posts to {}.", ctr, list_id);
}
UpdatePostfixConfig {
master_cf,
config:
PostfixConfig {
user,
group,
binary_path,
process_limit,
map_output_path,
transport_name,
},
} => {
let pfconf = mailpot::postfix::PostfixConfiguration {
user: user.into(),
group: group.map(Into::into),
binary_path,
process_limit,
map_output_path,
transport_name: transport_name.map(std::borrow::Cow::from),
};
pfconf.save_maps(db.conf())?;
pfconf.save_master_cf_entry(db.conf(), config_path, master_cf.as_deref())?;
}
PrintPostfixConfig {
config:
PostfixConfig {
user,
group,
binary_path,
process_limit,
map_output_path,
transport_name,
},
} => {
let pfconf = mailpot::postfix::PostfixConfiguration {
user: user.into(),
group: group.map(Into::into),
binary_path,
process_limit,
map_output_path,
transport_name: transport_name.map(std::borrow::Cow::from),
};
let lists = db.lists()?;
let lists_post_policies = lists
.into_iter()
.map(|l| {
let pk = l.pk;
Ok((l, db.list_post_policy(pk)?))
})
.collect::<Result<Vec<(DbVal<MailingList>, Option<DbVal<PostPolicy>>)>>>()?;
let maps = pfconf.generate_maps(&lists_post_policies);
let mastercf = pfconf.generate_master_cf_entry(db.conf(), config_path);
println!("{maps}\n\n{mastercf}\n");
}
Accounts => {
let accounts = db.accounts()?;
if accounts.is_empty() {
println!("No accounts found.");
} else {
for a in accounts {
println!("- {:?}", a);
}
}
}
AccountInfo { address } => {
if let Some(acc) = db.account_by_address(&address)? {
let subs = db.account_subscriptions(acc.pk())?;
if subs.is_empty() {
println!("No subscriptions found.");
} else {
for s in subs {
let list = db
.list(s.list)
.unwrap_or_else(|err| {
panic!(
"Found subscription with list_pk = {} but no such list \
exists.\nListSubscription = {:?}\n\n{err}",
s.list, s
)
})
.unwrap_or_else(|| {
panic!(
"Found subscription with list_pk = {} but no such list \
exists.\nListSubscription = {:?}",
s.list, s
)
});
println!("- {:?} {}", s, list);
}
}
} else {
println!("account with this address not found!");
};
}
AddAccount {
address,
password,
name,
public_key,
enabled,
} => {
db.add_account(Account {
pk: 0,
name,
address,
public_key,
password,
enabled: enabled.unwrap_or(true),
})?;
}
RemoveAccount { address } => {
let mut input = String::new();
loop {
println!(
"Are you sure you want to remove account with address {}? [Yy/n]",
address
);
input.clear();
std::io::stdin().read_line(&mut input)?;
if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
break;
} else if input.trim() == "n" {
return Ok(());
}
}
db.remove_account(&address)?;
}
UpdateAccount {
address,
password,
name,
public_key,
enabled,
} => {
let changeset = AccountChangeset {
address,
name,
public_key,
password,
enabled,
};
db.update_account(changeset)?;
}
Repair {
fix,
all,
mut datetime_header_value,
mut remove_empty_accounts,
mut remove_accepted_subscription_requests,
mut warn_list_no_owner,
} => {
type LintFn =
fn(&'_ mut mailpot::Connection, bool) -> std::result::Result<(), mailpot::Error>;
let dry_run = !fix;
if all {
datetime_header_value = true;
remove_empty_accounts = true;
remove_accepted_subscription_requests = true;
warn_list_no_owner = true;
}
if !(datetime_header_value
| remove_empty_accounts
| remove_accepted_subscription_requests
| warn_list_no_owner)
{
return Err(
"No lints selected: specify them with flag arguments. See --help".into(),
);
}
if dry_run {
println!("running without making modifications (dry run)");
}
for (flag, lint_fn) in [
(datetime_header_value, datetime_header_value_lint as LintFn),
(remove_empty_accounts, remove_empty_accounts_lint as _),
(
remove_accepted_subscription_requests,
remove_accepted_subscription_requests_lint as _,
),
(warn_list_no_owner, warn_list_no_owner_lint as _),
] {
if flag {
lint_fn(&mut db, dry_run)?;
}
}
}
}
Ok(())
}
fn main() -> std::result::Result<(), i32> {
let opt = Opt::parse();
stderrlog::new()
.module(module_path!())
.module("mailpot")
.quiet(opt.quiet)
.verbosity(opt.verbose as usize)
.timestamp(opt.ts.unwrap_or(stderrlog::Timestamp::Off))
.init()
.unwrap();
if let Err(err) = run_app(opt) {
print!("{}", err.display_chain());
std::process::exit(-1);
}
Ok(())
}

View File

@ -104,32 +104,6 @@ For more information, try '--help'."#,
let config_str = config.to_toml();
fn config_not_exists(conf: &Path) {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-c")
.arg(conf)
.arg("list-lists")
.output()
.unwrap()
.assert();
output.code(255).stderr(predicates::str::is_empty()).stdout(
predicate::eq(
format!(
"[1] Could not read configuration file from path: {path} Caused by:\n[2] \
Configuration file {path} not found. Caused by:\n[3] Error returned from \
internal I/O operation: No such file or directory (os error 2)",
path = conf.display()
)
.as_str(),
)
.trim()
.normalize(),
);
}
config_not_exists(&conf_path);
std::fs::write(&conf_path, config_str.as_bytes()).unwrap();
fn list_lists(conf: &Path, eq: &str) {
@ -204,65 +178,4 @@ For more information, try '--help'."#,
\"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
2)\n\tList owners: None\n\tPost policy: None\n\tSubscription policy: None",
);
fn add_list_owner(conf: &Path) {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-c")
.arg(conf)
.arg("list")
.arg("twobar-chat")
.arg("add-list-owner")
.arg("--address")
.arg("list-owner@example.com")
.output()
.unwrap()
.assert();
output.code(0).stderr(predicates::str::is_empty()).stdout(
predicate::eq("Added new list owner [#1 2] list-owner@example.com")
.trim()
.normalize(),
);
}
add_list_owner(&conf_path);
list_lists(
&conf_path,
"- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
\"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
\"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
2)\n\tList owners:\n\t- [#1 2] list-owner@example.com\n\tPost policy: \
None\n\tSubscription policy: None",
);
fn remove_list_owner(conf: &Path) {
let mut cmd = Command::cargo_bin("mpot").unwrap();
let output = cmd
.arg("-c")
.arg(conf)
.arg("list")
.arg("twobar-chat")
.arg("remove-list-owner")
.arg("--pk")
.arg("1")
.output()
.unwrap()
.assert();
output.code(0).stderr(predicates::str::is_empty()).stdout(
predicate::eq("Removed list owner with pk = 1")
.trim()
.normalize(),
);
}
remove_list_owner(&conf_path);
list_lists(
&conf_path,
"- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
\"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
\"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
2)\n\tList owners: None\n\tPost policy: None\n\tSubscription policy: None",
);
}

View File

@ -109,23 +109,11 @@ fn test_out_queue_flush() {
assert!(env.subject().starts_with(&format!("[{}] ", foo_chat.id)));
let headers = env.other_headers();
assert_eq!(headers.get("List-Id"), Some(&foo_chat.id_header()));
assert_eq!(headers.get("List-Help"), foo_chat.help_header().as_ref());
assert_eq!(
headers
.get(melib::HeaderName::LIST_ID)
.map(|header| header.to_string()),
Some(foo_chat.id_header())
);
assert_eq!(
headers
.get(melib::HeaderName::LIST_HELP)
.map(|header| header.to_string()),
foo_chat.help_header()
);
assert_eq!(
headers
.get(melib::HeaderName::LIST_POST)
.map(|header| header.to_string()),
foo_chat.post_header(Some(&post_policy))
headers.get("List-Post"),
foo_chat.post_header(Some(&post_policy)).as_ref()
);
};
@ -318,21 +306,11 @@ fn test_list_requests_submission() {
let headers_fn = |env: &melib::Envelope| {
let headers = env.other_headers();
assert_eq!(headers.get("List-Id"), Some(&foo_chat.id_header()));
assert_eq!(headers.get("List-Help"), foo_chat.help_header().as_ref());
assert_eq!(
headers.get(melib::HeaderName::LIST_ID),
Some(foo_chat.id_header().as_str())
);
assert_eq!(
headers
.get(melib::HeaderName::LIST_HELP)
.map(|header| header.to_string()),
foo_chat.help_header()
);
assert_eq!(
headers
.get(melib::HeaderName::LIST_POST)
.map(|header| header.to_string()),
foo_chat.post_header(Some(&post_policy))
headers.get("List-Post"),
foo_chat.post_header(Some(&post_policy)).as_ref()
);
};

View File

@ -18,10 +18,10 @@ anyhow = "1.0.58"
chrono = { version = "^0.4", features = ["serde", ] }
jsonschema = { version = "0.17", default-features = false }
log = "0.4"
melib = { default-features = false, features = ["mbox", "smtp", "unicode-algorithms", "maildir"], git = "https://git.meli-email.org/meli/meli.git", rev = "64e60cb" }
melib = { version = "*", default-features = false, features = ["smtp", "unicode_algorithms", "maildir_backend"], git = "https://github.com/meli/meli", rev = "2447a2c" }
minijinja = { version = "0.31.0", features = ["source", ] }
percent-encoding = { version = "^2.1" }
rusqlite = { version = "^0.30", features = ["bundled", "functions", "trace", "hooks", "serde_json", "array", "chrono", "unlock_notify"] }
rusqlite = { version = "^0.28", features = ["bundled", "functions", "trace", "hooks", "serde_json", "array", "chrono", "unlock_notify"] }
serde = { version = "^1", features = ["derive", ] }
serde_json = "^1"
thiserror = { version = "1.0.48", default-features = false }
@ -31,5 +31,5 @@ xdg = "2.4.1"
[dev-dependencies]
mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
reqwest = { version = "0.11", default-features = false, features = ["json", "blocking"] }
stderrlog = { version = "^0.6" }
tempfile = { version = "3.9" }
stderrlog = "^0.5"
tempfile = "3.3"

View File

@ -62,7 +62,7 @@ fn main() {
String::from_utf8_lossy(&output.stderr)
);
}
let user_version: i32 = make_migrations("migrations", MIGRATION_RS, &mut output.stdout);
make_migrations("migrations", MIGRATION_RS, &mut output.stdout);
let mut verify = Command::new(std::env::var("SQLITE_BIN").unwrap_or("sqlite3".into()))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@ -87,9 +87,4 @@ fn main() {
}
let mut file = std::fs::File::create("./src/schema.sql").unwrap();
file.write_all(&output.stdout).unwrap();
file.write_all(
&format!("\n\n-- Set current schema version.\n\nPRAGMA user_version = {user_version};\n")
.as_bytes(),
)
.unwrap();
}

View File

@ -25,13 +25,11 @@ use std::{fs::read_dir, io::Write, path::Path};
///
/// If a migration is a data migration (not a CREATE, DROP or ALTER statement) it is appended to
/// the schema file.
///
/// Returns the current `user_version` PRAGMA value.
pub fn make_migrations<M: AsRef<Path>, O: AsRef<Path>>(
migrations_path: M,
output_file: O,
schema_file: &mut Vec<u8>,
) -> i32 {
) {
let migrations_folder_path = migrations_path.as_ref();
let output_file_path = output_file.as_ref();
@ -106,5 +104,4 @@ pub fn make_migrations<M: AsRef<Path>, O: AsRef<Path>>(
}
migr_rs.write_all(b"]").unwrap();
migr_rs.flush().unwrap();
paths.len() as i32
}

View File

@ -76,18 +76,12 @@ impl Configuration {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let mut s = String::new();
let mut file = std::fs::File::open(path)
.with_context(|| format!("Configuration file {} not found.", path.display()))?;
file.read_to_string(&mut s)
.with_context(|| format!("Could not read from file {}.", path.display()))?;
let config: Self = toml::from_str(&s)
.map_err(anyhow::Error::from)
.with_context(|| {
format!(
"Could not parse configuration file `{}` successfully: ",
path.display()
)
})?;
let mut file = std::fs::File::open(path)?;
file.read_to_string(&mut s)?;
let config: Self = toml::from_str(&s).context(format!(
"Could not parse configuration file `{}` succesfully: ",
path.display()
))?;
Ok(config)
}
@ -110,20 +104,14 @@ impl Configuration {
}
debug_assert!(path != self.db_path());
let mut file = std::fs::File::create(&path)
.with_context(|| format!("Could not create file {}.", path.display()))?;
let metadata = file
.metadata()
.with_context(|| format!("Could not fstat file {}.", path.display()))?;
let mut file = std::fs::File::create(&path)?;
let metadata = file.metadata()?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o600); // Read/write for owner only.
file.set_permissions(permissions)
.with_context(|| format!("Could not chmod 600 file {}.", path.display()))?;
file.write_all(msg.as_bytes())
.with_context(|| format!("Could not write message to file {}.", path.display()))?;
file.flush()
.with_context(|| format!("Could not flush message I/O to file {}.", path.display()))?;
file.set_permissions(permissions)?;
file.write_all(msg.as_bytes())?;
file.flush()?;
Ok(path)
}
@ -139,29 +127,3 @@ impl Configuration {
.to_string()
}
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
#[test]
fn test_config_parse_error() {
let tmp_dir = TempDir::new().unwrap();
let conf_path = tmp_dir.path().join("conf.toml");
std::fs::write(&conf_path, b"afjsad skas as a as\n\n\n\n\t\x11\n").unwrap();
assert_eq!(
Configuration::from_file(&conf_path)
.unwrap_err()
.display_chain()
.to_string(),
format!(
"[1] Could not parse configuration file `{}` successfully: Caused by:\n[2] \
Error: expected an equals, found an identifier at line 1 column 8\n",
conf_path.display()
),
);
}
}

View File

@ -32,7 +32,6 @@ use crate::{
config::Configuration,
errors::{ErrorKind::*, *},
models::{changesets::MailingListChangeset, DbVal, ListOwner, MailingList, Post},
StripCarets,
};
/// A connection to a `mailpot` database.
@ -129,7 +128,7 @@ impl Connection {
/// ```sql
#[doc = include_str!("./schema.sql")]
/// ```
pub const SCHEMA: &'static str = include_str!("./schema.sql");
pub const SCHEMA: &str = include_str!("./schema.sql");
/// Database migrations.
pub const MIGRATIONS: &'static [(u32, &'static str, &'static str)] =
@ -188,9 +187,7 @@ impl Connection {
INIT_SQLITE_LOGGING.call_once(|| {
_ = unsafe { rusqlite::trace::config_log(Some(log_callback)) };
});
let conn = DbConnection::open(conf.db_path.to_str().unwrap()).with_context(|| {
format!("sqlite3 library could not open {}.", conf.db_path.display())
})?;
let conn = DbConnection::open(conf.db_path.to_str().unwrap())?;
rusqlite::vtab::array::load_module(&conn)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "on")?;
@ -349,14 +346,7 @@ impl Connection {
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| {
format!(
"Could not launch {} {}.",
std::env::var("SQLITE_BIN").unwrap_or_else(|_| "sqlite3".into()),
db_path.display()
)
})?;
.spawn()?;
let mut stdin = child.stdin.take().unwrap();
std::thread::spawn(move || {
stdin
@ -391,16 +381,12 @@ impl Connection {
.into());
}
let file = std::fs::File::open(db_path)
.with_context(|| format!("Could not open database {}.", db_path.display()))?;
let metadata = file
.metadata()
.with_context(|| format!("Could not fstat database {}.", db_path.display()))?;
let file = std::fs::File::open(db_path)?;
let metadata = file.metadata()?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o600); // Read/write for owner only.
file.set_permissions(permissions)
.with_context(|| format!("Could not chmod 600 database {}.", db_path.display()))?;
file.set_permissions(permissions)?;
}
Self::open_db(conf)
}
@ -593,164 +579,6 @@ impl Connection {
Ok(ret)
}
/// Fetch the contents of a single thread in the form of `(depth, post)`
/// where `depth` is the reply distance between a message and the thread
/// root message.
pub fn list_thread(&self, list_pk: i64, root: &str) -> Result<Vec<(i64, DbVal<Post>)>> {
let mut stmt = self
.connection
.prepare(
"WITH RECURSIVE cte_replies AS MATERIALIZED
(
SELECT
pk,
message_id,
REPLACE(
TRIM(
SUBSTR(
CAST(message AS TEXT),
INSTR(
CAST(message AS TEXT),
'In-Reply-To: '
)
+
LENGTH('in-reply-to: '),
INSTR(
SUBSTR(
CAST(message AS TEXT),
INSTR(
CAST(message AS TEXT),
'In-Reply-To: ')
+
LENGTH('in-reply-to: ')
),
'>'
)
)
),
' ',
''
) AS in_reply_to,
INSTR(
CAST(message AS TEXT),
'In-Reply-To: '
) AS offset
FROM post
WHERE
offset > 0
UNION
SELECT
pk,
message_id,
NULL AS in_reply_to,
INSTR(
CAST(message AS TEXT),
'In-Reply-To: '
) AS offset
FROM post
WHERE
offset = 0
),
cte_thread(parent, root, depth) AS (
SELECT DISTINCT
message_id AS parent,
message_id AS root,
0 AS depth
FROM cte_replies
WHERE
in_reply_to IS NULL
UNION ALL
SELECT
t.message_id AS parent,
cte_thread.root AS root,
(cte_thread.depth + 1) AS depth
FROM cte_replies
AS t
JOIN
cte_thread
ON cte_thread.parent = t.in_reply_to
WHERE t.in_reply_to IS NOT NULL
)
SELECT * FROM cte_thread WHERE root = ? ORDER BY root, depth;",
)
.unwrap();
let iter = stmt.query_map(rusqlite::params![root], |row| {
let parent: String = row.get("parent")?;
let root: String = row.get("root")?;
let depth: i64 = row.get("depth")?;
Ok((parent, root, depth))
})?;
let mut ret = vec![];
for post in iter {
ret.push(post?);
}
let posts = self.list_posts(list_pk, None)?;
let ret = ret
.into_iter()
.filter_map(|(m, _, depth)| {
posts
.iter()
.find(|p| m.as_str().strip_carets() == p.message_id.as_str().strip_carets())
.map(|p| (depth, p.clone()))
})
.skip(1)
.collect();
Ok(ret)
}
/// Export a list, message, or thread in mbox format
pub fn export_mbox(
&self,
pk: i64,
message_id: Option<&str>,
as_thread: bool,
) -> Result<Vec<u8>> {
let posts: Result<Vec<DbVal<Post>>> = {
if let Some(message_id) = message_id {
if as_thread {
// export a thread
let thread = self.list_thread(pk, message_id)?;
Ok(thread.iter().map(|item| item.1.clone()).collect())
} else {
// export a single message
let message =
self.list_post_by_message_id(pk, message_id)?
.ok_or_else(|| {
Error::from(format!("no message with id: {}", message_id))
})?;
Ok(vec![message])
}
} else {
// export the entire mailing list
let posts = self.list_posts(pk, None)?;
Ok(posts)
}
};
let mut buf: Vec<u8> = Vec::new();
let mailbox = melib::mbox::MboxFormat::default();
for post in posts? {
let envelope_from = if let Some(address) = post.0.envelope_from {
let address = melib::Address::try_from(address.as_str())?;
Some(address)
} else {
None
};
let envelope = melib::Envelope::from_bytes(&post.0.message, None)?;
mailbox.append(
&mut buf,
&post.0.message.to_vec(),
envelope_from.as_ref(),
Some(envelope.timestamp),
(melib::Flag::PASSED, vec![]),
melib::mbox::MboxMetadata::None,
false,
false,
)?;
}
buf.flush()?;
Ok(buf)
}
/// Fetch the owners of a mailing list.
pub fn list_owners(&self, pk: i64) -> Result<Vec<DbVal<ListOwner>>> {
let mut stmt = self
@ -789,7 +617,7 @@ SELECT * FROM cte_thread WHERE root = ? ORDER BY root, depth;",
if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
Error::from(err).chain_err(|| NotFound("list or list owner not found!"))
} else {
Error::from(err)
err.into()
}
})?;
Ok(())
@ -1315,67 +1143,4 @@ mod tests {
tx.commit().unwrap();
assert_eq!(&db.lists().unwrap(), &[new, new2, new3]);
}
#[test]
fn test_mbox_export() {
use tempfile::TempDir;
use crate::SendMail;
let tmp_dir = TempDir::new().unwrap();
let db_path = tmp_dir.path().join("mpot.db");
let data_path = tmp_dir.path().to_path_buf();
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path,
administrators: vec![],
};
let list = MailingList {
pk: 0,
name: "test".into(),
id: "test".into(),
description: None,
topics: vec![],
address: "test@example.com".into(),
archive_url: None,
};
let test_emails = vec![
r#"From: "User Name" <user@example.com>
To: "test" <test@example.com>
Subject: Hello World
Hello, this is a message.
Goodbye!
"#,
r#"From: "User Name" <user@example.com>
To: "test" <test@example.com>
Subject: Fuu Bar
Baz,
Qux!
"#,
];
let db = Connection::open_or_create_db(config).unwrap().trusted();
db.create_list(list).unwrap();
for email in test_emails {
let envelope = melib::Envelope::from_bytes(email.as_bytes(), None).unwrap();
db.post(&envelope, email.as_bytes(), false).unwrap();
}
let mbox = String::from_utf8(db.export_mbox(1, None, false).unwrap()).unwrap();
assert!(
mbox.split('\n').fold(0, |accm, line| {
if line.starts_with("From MAILER-DAEMON") {
accm + 1
} else {
accm
}
}) == 2
)
}
}

View File

@ -23,6 +23,8 @@ use std::sync::Arc;
use thiserror::Error;
pub use crate::anyhow::Context;
/// Mailpot library error.
#[derive(Error, Debug)]
pub struct Error {
@ -51,36 +53,39 @@ pub enum ErrorKind {
/// Error returned from an external user initiated operation such as
/// deserialization or I/O.
#[error("Error: {0}")]
#[error(
"Error returned from an external user initiated operation such as deserialization or I/O. \
{0}"
)]
External(#[from] anyhow::Error),
/// Generic
#[error("{0}")]
Generic(anyhow::Error),
/// Error returned from sqlite3.
#[error("Error returned from sqlite3: {0}.")]
#[error("Error returned from sqlite3 {0}.")]
Sql(
#[from]
#[source]
rusqlite::Error,
),
/// Error returned from sqlite3.
#[error("Error returned from sqlite3: {0}")]
#[error("Error returned from sqlite3. {0}")]
SqlLib(
#[from]
#[source]
rusqlite::ffi::Error,
),
/// Error returned from internal I/O operations.
#[error("Error returned from internal I/O operation: {0}")]
#[error("Error returned from internal I/O operations. {0}")]
Io(#[from] ::std::io::Error),
/// Error returned from e-mail protocol operations from `melib` crate.
#[error("Error returned from e-mail protocol operations from `melib` crate: {0}")]
#[error("Error returned from e-mail protocol operations from `melib` crate. {0}")]
Melib(#[from] melib::error::Error),
/// Error from deserializing JSON values.
#[error("Error from deserializing JSON values: {0}")]
#[error("Error from deserializing JSON values. {0}")]
SerdeJson(#[from] serde_json::Error),
/// Error returned from minijinja template engine.
#[error("Error returned from minijinja template engine: {0}")]
#[error("Error returned from minijinja template engine. {0}")]
Template(#[from] minijinja::Error),
}
@ -183,7 +188,7 @@ struct ErrorChainDisplay<'e> {
impl std::fmt::Display for ErrorChainDisplay<'_> {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
if let Some(ref source) = self.current.source {
writeln!(fmt, "[{}] {} Caused by:", self.counter, self.current.kind)?;
writeln!(fmt, "[{}] {}, caused by:", self.counter, self.current.kind)?;
Self {
current: source,
counter: self.counter + 1,
@ -195,38 +200,3 @@ impl std::fmt::Display for ErrorChainDisplay<'_> {
}
}
}
/// adfsa
pub trait Context<T> {
/// Wrap the error value with additional context.
fn context<C>(self, context: C) -> Result<T>
where
C: Into<Error>;
/// Wrap the error value with additional context that is evaluated lazily
/// only once an error does occur.
fn with_context<C, F>(self, f: F) -> Result<T>
where
C: Into<Error>,
F: FnOnce() -> C;
}
impl<T, E> Context<T> for std::result::Result<T, E>
where
Error: From<E>,
{
fn context<C>(self, context: C) -> Result<T>
where
C: Into<Error>,
{
self.map_err(|err| Error::from(err).chain_err(|| context.into()))
}
fn with_context<C, F>(self, f: F) -> Result<T>
where
C: Into<Error>,
F: FnOnce() -> C,
{
self.map_err(|err| Error::from(err).chain_err(|| f().into()))
}
}

View File

@ -221,33 +221,6 @@ impl StripCarets for &str {
}
}
/// Trait for stripping carets ('<','>') from Message IDs inplace.
pub trait StripCaretsInplace {
/// If `self` is surrounded by carets, strip them.
fn strip_carets_inplace(self) -> Self;
}
impl StripCaretsInplace for &str {
fn strip_carets_inplace(self) -> Self {
let mut self_ref = self.trim();
if self_ref.starts_with('<') && self_ref.ends_with('>') {
self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
}
self_ref
}
}
impl StripCaretsInplace for String {
fn strip_carets_inplace(mut self) -> Self {
if self.starts_with('<') && self.ends_with('>') {
self.drain(0..1);
let len = self.len();
self.drain(len.saturating_sub(1)..len);
}
self
}
}
use percent_encoding::CONTROLS;
pub use percent_encoding::{utf8_percent_encode, AsciiSet};

View File

@ -40,8 +40,9 @@
mod settings;
use log::trace;
use melib::{Address, HeaderName};
use melib::Address;
use percent_encoding::utf8_percent_encode;
pub use settings::*;
use crate::{
mail::{ListContext, MailJob, PostAction, PostEntry},
@ -167,7 +168,7 @@ impl PostFilter for AddListHeaders {
trace!("Running AddListHeaders filter");
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
let sender = format!("<{}>", ctx.list.address);
headers.push((HeaderName::SENDER, sender.as_bytes()));
headers.push((&b"Sender"[..], sender.as_bytes()));
let list_id = Some(ctx.list.id_header());
let list_help = ctx.list.help_header();
@ -181,12 +182,12 @@ impl PostFilter for AddListHeaders {
let list_archive = ctx.list.archive_header();
for (hdr, val) in [
(HeaderName::LIST_ID, &list_id),
(HeaderName::LIST_HELP, &list_help),
(HeaderName::LIST_POST, &list_post),
(HeaderName::LIST_UNSUBSCRIBE, &list_unsubscribe),
(HeaderName::LIST_SUBSCRIBE, &list_subscribe),
(HeaderName::LIST_ARCHIVE, &list_archive),
(b"List-Id".as_slice(), &list_id),
(b"List-Help".as_slice(), &list_help),
(b"List-Post".as_slice(), &list_post),
(b"List-Unsubscribe".as_slice(), &list_unsubscribe),
(b"List-Subscribe".as_slice(), &list_subscribe),
(b"List-Archive".as_slice(), &list_archive),
] {
if let Some(val) = val {
headers.push((hdr, val.as_bytes()));
@ -196,13 +197,13 @@ impl PostFilter for AddListHeaders {
let mut new_vec = Vec::with_capacity(
headers
.iter()
.map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
.map(|(h, v)| h.len() + v.len() + ": \r\n".len())
.sum::<usize>()
+ "\r\n\r\n".len()
+ body.len(),
);
for (h, v) in headers {
new_vec.extend_from_slice(h.as_str().as_bytes());
new_vec.extend_from_slice(h);
new_vec.extend_from_slice(b": ");
new_vec.extend_from_slice(v);
new_vec.extend_from_slice(b"\r\n");
@ -238,25 +239,28 @@ impl PostFilter for AddSubjectTagPrefix {
trace!("Running AddSubjectTagPrefix filter");
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
let mut subject;
if let Some((_, subj_val)) = headers.iter_mut().find(|(k, _)| k == HeaderName::SUBJECT) {
if let Some((_, subj_val)) = headers
.iter_mut()
.find(|(k, _)| k.eq_ignore_ascii_case(b"Subject"))
{
subject = format!("[{}] ", ctx.list.id).into_bytes();
subject.extend(subj_val.iter().cloned());
*subj_val = subject.as_slice();
} else {
subject = format!("[{}] (no subject)", ctx.list.id).into_bytes();
headers.push((HeaderName::SUBJECT, subject.as_slice()));
headers.push((&b"Subject"[..], subject.as_slice()));
}
let mut new_vec = Vec::with_capacity(
headers
.iter()
.map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
.map(|(h, v)| h.len() + v.len() + ": \r\n".len())
.sum::<usize>()
+ "\r\n\r\n".len()
+ body.len(),
);
for (h, v) in headers {
new_vec.extend_from_slice(h.as_str().as_bytes());
new_vec.extend_from_slice(h);
new_vec.extend_from_slice(b": ");
new_vec.extend_from_slice(v);
new_vec.extend_from_slice(b"\r\n");
@ -311,18 +315,18 @@ impl PostFilter for ArchivedAtLink {
log::error!("ArchivedAtLink: {}", err);
})?;
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
headers.push((HeaderName::ARCHIVED_AT, header_val.as_bytes()));
headers.push((&b"Archived-At"[..], header_val.as_bytes()));
let mut new_vec = Vec::with_capacity(
headers
.iter()
.map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
.map(|(h, v)| h.len() + v.len() + ": \r\n".len())
.sum::<usize>()
+ "\r\n\r\n".len()
+ body.len(),
);
for (h, v) in headers {
new_vec.extend_from_slice(h.as_str().as_bytes());
new_vec.extend_from_slice(h);
new_vec.extend_from_slice(b": ");
new_vec.extend_from_slice(v);
new_vec.extend_from_slice(b"\r\n");

View File

@ -320,7 +320,7 @@ impl MailingList {
Address::new(Some(self.name.clone()), self.address.clone())
}
/// List unsubscribe action as a [`MailtoAddress`].
/// List unsubscribe action as a [`MailtoAddress`](super::MailtoAddress).
pub fn unsubscription_mailto(&self) -> MailtoAddress {
MailtoAddress {
address: self.request_subaddr(),
@ -328,7 +328,7 @@ impl MailingList {
}
}
/// List subscribe action as a [`MailtoAddress`].
/// List subscribe action as a [`MailtoAddress`](super::MailtoAddress).
pub fn subscription_mailto(&self) -> MailtoAddress {
MailtoAddress {
address: self.request_subaddr(),
@ -336,7 +336,7 @@ impl MailingList {
}
}
/// List owner as a [`MailtoAddress`].
/// List owner as a [`MailtoAddress`](super::MailtoAddress).
pub fn owner_mailto(&self) -> MailtoAddress {
let p = self.address.split('@').collect::<Vec<&str>>();
MailtoAddress {
@ -371,7 +371,7 @@ impl MailingList {
if let Some(val) = val {
draft
.headers
.insert(melib::HeaderName::try_from(hdr).unwrap(), val);
.insert(melib::HeaderName::new_unchecked(hdr), val);
}
}
}
@ -728,13 +728,6 @@ pub struct ListCandidateSubscription {
pub accepted: Option<i64>,
}
impl ListCandidateSubscription {
/// Subscription request address as a [`melib::Address`]
pub fn address(&self) -> Address {
Address::new(self.name.clone(), self.address.clone())
}
}
impl std::fmt::Display for ListCandidateSubscription {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(

View File

@ -19,6 +19,9 @@
//! How each list handles new posts and new subscriptions.
pub use post_policy::*;
pub use subscription_policy::*;
mod post_policy {
use log::trace;
use rusqlite::OptionalExtension;

View File

@ -67,7 +67,7 @@ pub struct PostfixConfiguration {
#[serde(default)]
pub process_limit: Option<u64>,
/// The directory in which the map files are saved.
/// Default is `data_path` from [`Configuration`].
/// Default is `data_path` from [`Configuration`](crate::Configuration).
#[serde(default)]
pub map_output_path: Option<PathBuf>,
/// The name of the Postfix service name to use.
@ -412,7 +412,6 @@ fn test_postfix_generation() -> Result<()> {
let mut conf = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&config_path)?;
conf.write_all(config.to_toml().as_bytes())?;
conf.flush()?;
@ -576,11 +575,7 @@ mailman unix - n n - - pipe
let path = tmp_dir.path().join("master.cf");
{
let mut mastercf = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
let mut mastercf = OpenOptions::new().write(true).create(true).open(&path)?;
mastercf.write_all(master_edit_value.as_bytes())?;
mastercf.flush()?;
}

View File

@ -43,12 +43,12 @@ impl Connection {
} else {
from_[0].get_email()
};
let datetime: std::borrow::Cow<'_, str> = if !env.date.is_empty() {
let datetime: std::borrow::Cow<'_, str> = if !env.date.as_str().is_empty() {
env.date.as_str().into()
} else {
melib::utils::datetime::timestamp_to_string(
melib::datetime::timestamp_to_string(
env.timestamp,
Some(melib::utils::datetime::formats::RFC822_DATE),
Some(melib::datetime::RFC822_DATE),
true,
)
.into()
@ -129,6 +129,9 @@ impl Connection {
return Err("Envelope From: field is empty!".into());
}
let mut lists = self.lists()?;
if lists.is_empty() {
return Err("No active mailing lists found.".into());
}
let prev_list_len = lists.len();
for t in &tos {
if let Some((addr, subaddr)) = t.subaddress("+") {
@ -302,6 +305,7 @@ impl Connection {
env: &Envelope,
raw: &[u8],
) -> Result<()> {
let post_policy = self.list_post_policy(list.pk)?;
match request {
ListRequest::Help => {
trace!(
@ -310,7 +314,6 @@ impl Connection {
list
);
let subscription_policy = self.list_subscription_policy(list.pk)?;
let post_policy = self.list_post_policy(list.pk)?;
let subject = format!("Help for {}", list.name);
let details = list
.generate_help_email(post_policy.as_deref(), subscription_policy.as_deref());
@ -338,10 +341,9 @@ impl Connection {
env.from(),
list
);
let subscription_policy = self.list_subscription_policy(list.pk)?;
let approval_needed = subscription_policy
let approval_needed = post_policy
.as_ref()
.map(|p| !p.open)
.map(|p| p.approval_needed)
.unwrap_or(false);
for f in env.from() {
let email_from = f.get_email();
@ -488,7 +490,23 @@ impl Connection {
list_owners.iter().map(|owner| Cow::Owned(owner.address())),
)?;
} else {
self.send_subscription_confirmation(list, f)?;
log::trace!(
"Added subscription to list {list:?} for address {f:?}, sending \
confirmation."
);
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::SUBSCRIPTION_CONFIRMATION,
default_fn: Some(Template::default_subscription_confirmation),
list,
context: minijinja::context! {
list => &list,
},
queue: Queue::Out,
comment: Template::SUBSCRIPTION_CONFIRMATION.into(),
},
std::iter::once(Cow::Borrowed(f)),
)?;
}
}
}
@ -535,7 +553,19 @@ impl Connection {
list_owners.iter().map(|owner| Cow::Owned(owner.address())),
)?;
} else {
self.send_unsubscription_confirmation(list, f)?;
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::UNSUBSCRIPTION_CONFIRMATION,
default_fn: Some(Template::default_unsubscription_confirmation),
list,
context: minijinja::context! {
list => &list,
},
queue: Queue::Out,
comment: Template::UNSUBSCRIPTION_CONFIRMATION.into(),
},
std::iter::once(Cow::Borrowed(f)),
)?;
}
}
}
@ -658,7 +688,7 @@ impl Connection {
) -> Result<Option<DbVal<Post>>> {
let mut stmt = self.connection.prepare(
"SELECT *, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') AS month_year \
FROM post WHERE list = ?1 AND (message_id = ?2 OR concat('<', ?2, '>') = message_id);",
FROM post WHERE list = ? AND message_id = ?;",
)?;
let ret = stmt
.query_row(rusqlite::params![&list_pk, &message_id], |row| {
@ -710,14 +740,15 @@ impl Connection {
})?;
let mut draft = templ.render(context)?;
draft
.headers
.insert(melib::HeaderName::FROM, list.request_subaddr());
draft.headers.insert(
melib::HeaderName::new_unchecked("From"),
list.request_subaddr(),
);
for addr in recipients {
let mut draft = draft.clone();
draft
.headers
.insert(melib::HeaderName::TO, addr.to_string());
.insert(melib::HeaderName::new_unchecked("To"), addr.to_string());
list.insert_headers(
&mut draft,
post_policy.as_deref(),
@ -733,54 +764,6 @@ impl Connection {
}
Ok(())
}
/// Send subscription confirmation.
pub fn send_subscription_confirmation(
&self,
list: &DbVal<MailingList>,
address: &melib::Address,
) -> Result<()> {
log::trace!(
"Added subscription to list {list:?} for address {address:?}, sending confirmation."
);
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::SUBSCRIPTION_CONFIRMATION,
default_fn: Some(Template::default_subscription_confirmation),
list,
context: minijinja::context! {
list => &list,
},
queue: Queue::Out,
comment: Template::SUBSCRIPTION_CONFIRMATION.into(),
},
std::iter::once(Cow::Borrowed(address)),
)
}
/// Send unsubscription confirmation.
pub fn send_unsubscription_confirmation(
&self,
list: &DbVal<MailingList>,
address: &melib::Address,
) -> Result<()> {
log::trace!(
"Removed subscription to list {list:?} for address {address:?}, sending confirmation."
);
self.send_reply_with_list_template(
TemplateRenderContext {
template: Template::UNSUBSCRIPTION_CONFIRMATION,
default_fn: Some(Template::default_unsubscription_confirmation),
list,
context: minijinja::context! {
list => &list,
},
queue: Queue::Out,
comment: Template::UNSUBSCRIPTION_CONFIRMATION.into(),
},
std::iter::once(Cow::Borrowed(address)),
)
}
}
/// Helper type for [`Connection::send_reply_with_list_template`].

View File

@ -29,7 +29,7 @@ use crate::{errors::*, models::DbVal, Connection, DateTime};
#[derive(Copy, Clone, Eq, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Queue {
/// Messages that have been received but not yet processed, await
/// Messages that have been submitted but not yet processed, await
/// processing in the `maildrop` queue. Messages can be added to the
/// `maildrop` queue even when mailpot is not running.
Maildrop,
@ -69,7 +69,7 @@ impl std::str::FromStr for Queue {
impl Queue {
/// Returns the name of the queue used in the database schema.
pub const fn as_str(&self) -> &'static str {
pub fn as_str(&self) -> &'static str {
match self {
Self::Maildrop => "maildrop",
Self::Hold => "hold",
@ -79,19 +79,6 @@ impl Queue {
Self::Error => "error",
}
}
/// Returns all possible variants as `&'static str`
pub const fn possible_values() -> &'static [&'static str] {
const VALUES: &[&str] = &[
Queue::Maildrop.as_str(),
Queue::Hold.as_str(),
Queue::Deferred.as_str(),
Queue::Corrupt.as_str(),
Queue::Out.as_str(),
Queue::Error.as_str(),
];
VALUES
}
}
impl std::fmt::Display for Queue {

View File

@ -650,8 +650,3 @@ INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('MimeRejectSetting
}
}
}');
-- Set current schema version.
PRAGMA user_version = 7;

View File

@ -33,11 +33,11 @@ use crate::{
impl Connection {
/// Fetch all subscriptions of a mailing list.
pub fn list_subscriptions(&self, list_pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
pub fn list_subscriptions(&self, pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM subscription WHERE list = ?;")?;
let list_iter = stmt.query_map([&list_pk], |row| {
let list_iter = stmt.query_map([&pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListSubscription {
@ -186,36 +186,6 @@ impl Connection {
self.list_subscription(list_pk, val.pk())
}
/// Fetch all candidate subscriptions of a mailing list.
pub fn list_subscription_requests(
&self,
list_pk: i64,
) -> Result<Vec<DbVal<ListCandidateSubscription>>> {
let mut stmt = self
.connection
.prepare("SELECT * FROM candidate_subscription WHERE list = ?;")?;
let list_iter = stmt.query_map([&list_pk], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
ListCandidateSubscription {
pk: row.get("pk")?,
list: row.get("list")?,
address: row.get("address")?,
name: row.get("name")?,
accepted: row.get("accepted")?,
},
pk,
))
})?;
let mut ret = vec![];
for list in list_iter {
let list = list?;
ret.push(list);
}
Ok(ret)
}
/// Create subscription candidate.
pub fn add_candidate_subscription(
&self,

View File

@ -54,23 +54,22 @@ impl std::fmt::Display for Template {
impl Template {
/// Template name for generic list help e-mail.
pub const GENERIC_HELP: &'static str = "generic-help";
pub const GENERIC_HELP: &str = "generic-help";
/// Template name for generic failure e-mail.
pub const GENERIC_FAILURE: &'static str = "generic-failure";
pub const GENERIC_FAILURE: &str = "generic-failure";
/// Template name for generic success e-mail.
pub const GENERIC_SUCCESS: &'static str = "generic-success";
pub const GENERIC_SUCCESS: &str = "generic-success";
/// Template name for subscription confirmation e-mail.
pub const SUBSCRIPTION_CONFIRMATION: &'static str = "subscription-confirmation";
pub const SUBSCRIPTION_CONFIRMATION: &str = "subscription-confirmation";
/// Template name for unsubscription confirmation e-mail.
pub const UNSUBSCRIPTION_CONFIRMATION: &'static str = "unsubscription-confirmation";
pub const UNSUBSCRIPTION_CONFIRMATION: &str = "unsubscription-confirmation";
/// Template name for subscription request notice e-mail (for list owners).
pub const SUBSCRIPTION_REQUEST_NOTICE_OWNER: &'static str = "subscription-notice-owner";
pub const SUBSCRIPTION_REQUEST_NOTICE_OWNER: &str = "subscription-notice-owner";
/// Template name for subscription request acceptance e-mail (for the
/// candidates).
pub const SUBSCRIPTION_REQUEST_CANDIDATE_ACCEPT: &'static str =
"subscription-notice-candidate-accept";
pub const SUBSCRIPTION_REQUEST_CANDIDATE_ACCEPT: &str = "subscription-notice-candidate-accept";
/// Template name for admin notices.
pub const ADMIN_NOTICE: &'static str = "admin-notice";
pub const ADMIN_NOTICE: &str = "admin-notice";
/// Render a message body from a saved named template.
pub fn render(&self, context: minijinja::value::Value) -> Result<melib::Draft> {
@ -83,7 +82,7 @@ impl Template {
};
if let Some(ref subject) = self.subject {
draft.headers.insert(
HeaderName::SUBJECT,
HeaderName::new_unchecked("Subject"),
env.render_named_str("subject", subject, &context)?,
);
}
@ -179,11 +178,10 @@ impl Template {
pk: -1,
name: Self::SUBSCRIPTION_REQUEST_NOTICE_OWNER.to_string(),
list: None,
subject: Some("Subscription request for {{ list.id }}".to_string()),
subject: Some("Subscription request for {{ list.id }} by {{ candidate }}".to_string()),
headers_json: None,
body: "Candidate {{ candidate.name if candidate.name else \"\" }} <{{ \
candidate.address }}> Primary key: {{ candidate.pk }}\n\n{{ details|safe if \
details else \"\" }}"
body: "Candidate primary key: {{ candidate.pk }}\n\n{{ details|safe if details else \
\"\" }}"
.to_string(),
}
}

View File

@ -23,7 +23,7 @@ use mailpot::{Configuration, Connection, SendMail};
use mailpot_tests::init_stderr_logging;
use tempfile::TempDir;
include!("../build/make_migrations.rs");
include!("../make_migrations.rs");
#[test]
fn test_init_empty() {

View File

@ -18,24 +18,12 @@ Tool for mailpot mailing list management.
.TP
\-d, \-\-debug
Print logs.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-c, \-\-config \fICONFIG\fR
Configuration file to use.
.TP
\-q, \-\-quiet
Silence all output.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-v, \-\-verbose
Verbose mode (\-v, \-vv, \-vvv, etc).
@ -113,12 +101,6 @@ Prints a sample config file to STDOUT.
.TP
\-\-with\-smtp
Use an SMTP connection instead of a shell process.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
@ -168,16 +150,6 @@ List subscriptions of list.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list subscription-requests
.\fR
.br
.br
List subscription requests.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list add-subscription
.\fR
.br
@ -358,47 +330,6 @@ Is subscription enabled.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list accept-subscription-request
.\fR
.br
.br
mpot list accept\-subscription\-request [\-\-do\-not\-send\-confirmation \fIDO_NOT_SEND_CONFIRMATION\fR] \fIPK\fR
.br
Accept a subscription request by its primary key.
.TP
\fIPK\fR
The primary key of the request.
.TP
\-\-do\-not\-send\-confirmation
Do not send confirmation e\-mail.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list send-confirmation-for-subscription
.\fR
.br
.br
mpot list send\-confirmation\-for\-subscription \fIPK\fR
.br
Send subscription confirmation manually.
.TP
\fIPK\fR
The primary key of the subscription.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot list add-post-policy
.\fR
.br
@ -412,48 +343,18 @@ Add a new post policy.
.TP
\-\-announce\-only
Only list owners can post.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-subscription\-only
Only subscriptions can post.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-approval\-needed
Subscriptions can post. Other posts must be approved by list owners.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-open
Anyone can post without restrictions.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-custom
Allow posts, but handle it manually.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
@ -485,48 +386,18 @@ Add subscription policy to list.
.TP
\-\-send\-confirmation
Send confirmation e\-mail when subscription is finalized.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-open
Anyone can subscribe without restrictions.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-manual
Only list owners can manually add subscriptions.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-request
Anyone can request to subscribe.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-custom
Allow subscriptions, but handle it manually.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
@ -723,21 +594,9 @@ List ID of remote list to query.
.TP
\-\-dry\-run
Show what would be inserted without performing any changes.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-skip\-owners
Don\*(Aqt import list owners.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
@ -782,12 +641,6 @@ Post message from STDIN to list.
.TP
\-\-dry\-run
Show e\-mail processing result without actually consuming it.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
@ -804,12 +657,61 @@ Flush outgoing e\-mail queue.
.TP
\-\-dry\-run
Show e\-mail processing result without actually consuming it.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot error-queue
.\fR
.br
.br
Mail that has not been handled properly end up in the error queue.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot error-queue list
.\fR
.br
[\fIpossible values: \fRtrue, false]
.br
List.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot error-queue print
.\fR
.br
.br
mpot error\-queue print [\-\-index \fIINDEX\fR]
.br
Print entry in RFC5322 or JSON format.
.TP
\-\-index \fIINDEX\fR
index of entry.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
.SS mpot error-queue delete
.\fR
.br
.br
mpot error\-queue delete [\-\-index \fIINDEX\fR] [\-\-quiet \fIQUIET\fR]
.br
Delete entry and print it in stdout.
.TP
\-\-index \fIINDEX\fR
index of entry.
.TP
\-\-quiet
Do not print in stdout.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
@ -822,12 +724,10 @@ Show e\-mail processing result without actually consuming it.
mpot queue \-\-queue \fIQUEUE\fR
.br
Processed mail is stored in queues.
Mail that has not been handled properly end up in the error queue.
.TP
\-\-queue \fIQUEUE\fR
.br
[\fIpossible values: \fRmaildrop, hold, deferred, corrupt, out, error]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
@ -863,13 +763,16 @@ index of entry.
.br
mpot queue delete [\-\-index \fIINDEX\fR]
mpot queue delete [\-\-index \fIINDEX\fR] [\-\-quiet \fIQUIET\fR]
.br
Delete entry and print it in stdout.
.TP
\-\-index \fIINDEX\fR
index of entry.
.TP
\-\-quiet
Do not print in stdout.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.\fB
@ -1100,57 +1003,21 @@ Show and fix possible data mistakes or inconsistencies.
.TP
\-\-fix
Fix errors (default: false).
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-all
Select all tests (default: false).
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-datetime\-header\-value
Post `datetime` column must have the Date: header value, in RFC2822 format.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-remove\-empty\-accounts
Remove accounts that have no matching subscriptions.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-remove\-accepted\-subscription\-requests
Remove subscription requests that have been accepted.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.TP
\-\-warn\-list\-no\-owner
Warn if a list has no owners.
.br
.br
.br
[\fIpossible values: \fRtrue, false]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH AUTHORS

View File

@ -1 +0,0 @@
../rustfmt.toml

View File

@ -1 +0,0 @@
../rustfmt.toml

File diff suppressed because it is too large Load Diff

View File

@ -1,221 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
use mailpot::{melib::smtp, Configuration, Connection, Context, Result};
use mailpot_cli::{commands::*, *};
fn run_app(
config: Option<PathBuf>,
cmd: Command,
debug: bool,
quiet: bool,
verbose: u8,
) -> Result<()> {
if let Command::SampleConfig { with_smtp } = cmd {
let mut new = Configuration::new("/path/to/sqlite.db");
new.administrators.push("admin@example.com".to_string());
if with_smtp {
new.send_mail = mailpot::SendMail::Smtp(smtp::SmtpServerConf {
hostname: "mail.example.com".to_string(),
port: 587,
envelope_from: "".to_string(),
auth: smtp::SmtpAuth::Auto {
username: "user".to_string(),
password: smtp::Password::Raw("hunter2".to_string()),
auth_type: smtp::SmtpAuthType::default(),
require_auth: true,
},
security: smtp::SmtpSecurity::StartTLS {
danger_accept_invalid_certs: false,
},
extensions: Default::default(),
});
}
println!("{}", new.to_toml());
return Ok(());
};
let config_path = if let Some(path) = config.as_deref() {
path
} else {
let mut opt = Opt::command();
opt.error(
clap::error::ErrorKind::MissingRequiredArgument,
"--config is required for mailing list operations",
)
.exit();
};
let config = Configuration::from_file(config_path).with_context(|| {
format!(
"Could not read configuration file from path: {}",
config_path.display()
)
})?;
use Command::*;
let mut db = Connection::open_or_create_db(config)
.context("Could not open database connection with this configuration")?
.trusted();
match cmd {
SampleConfig { .. } => {}
DumpDatabase => {
dump_database(&mut db).context("Could not dump database.")?;
}
ListLists => {
list_lists(&mut db).context("Could not retrieve mailing lists.")?;
}
List { list_id, cmd } => {
list(&mut db, &list_id, cmd, quiet).map_err(|err| {
err.chain_err(|| {
mailpot::Error::from(format!("Could not perform list command for {list_id}."))
})
})?;
}
CreateList {
name,
id,
address,
description,
archive_url,
} => {
create_list(&mut db, name, id, address, description, archive_url, quiet)
.context("Could not create list.")?;
}
Post { dry_run } => {
post(&mut db, dry_run, debug).context("Could not process post.")?;
}
FlushQueue { dry_run } => {
flush_queue(&mut db, dry_run, verbose, debug).with_context(|| {
format!("Could not flush queue {}.", mailpot::queue::Queue::Out)
})?;
}
Queue { queue, cmd } => {
queue_(&mut db, queue, cmd, quiet)
.with_context(|| format!("Could not perform queue command for queue `{queue}`."))?;
}
ImportMaildir {
list_id,
maildir_path,
} => {
import_maildir(
&mut db,
&list_id,
maildir_path.clone(),
quiet,
debug,
verbose,
)
.with_context(|| {
format!(
"Could not import maildir path {} to list `{list_id}`.",
maildir_path.display(),
)
})?;
}
UpdatePostfixConfig { master_cf, config } => {
update_postfix_config(config_path, &mut db, master_cf, config)
.context("Could not update postfix configuration.")?;
}
PrintPostfixConfig { config } => {
print_postfix_config(config_path, &mut db, config)
.context("Could not print postfix configuration.")?;
}
Accounts => {
accounts(&mut db, quiet).context("Could not retrieve accounts.")?;
}
AccountInfo { address } => {
account_info(&mut db, &address, quiet).with_context(|| {
format!("Could not retrieve account info for address {address}.")
})?;
}
AddAccount {
address,
password,
name,
public_key,
enabled,
} => {
add_account(&mut db, address, password, name, public_key, enabled)
.context("Could not add account.")?;
}
RemoveAccount { address } => {
remove_account(&mut db, &address, quiet)
.with_context(|| format!("Could not remove account with address {address}."))?;
}
UpdateAccount {
address,
password,
name,
public_key,
enabled,
} => {
update_account(&mut db, address, password, name, public_key, enabled)
.context("Could not update account.")?;
}
Repair {
fix,
all,
datetime_header_value,
remove_empty_accounts,
remove_accepted_subscription_requests,
warn_list_no_owner,
} => {
repair(
&mut db,
fix,
all,
datetime_header_value,
remove_empty_accounts,
remove_accepted_subscription_requests,
warn_list_no_owner,
)
.context("Could not perform database repair.")?;
}
}
Ok(())
}
fn main() -> std::result::Result<(), i32> {
let opt = Opt::parse();
stderrlog::new()
.module(module_path!())
.module("mailpot")
.quiet(opt.quiet)
.verbosity(opt.verbose as usize)
.timestamp(opt.ts.unwrap_or(stderrlog::Timestamp::Off))
.init()
.unwrap();
if opt.debug {
println!("DEBUG: {:?}", &opt);
}
let Opt {
config,
cmd,
debug,
quiet,
verbose,
..
} = opt;
if let Err(err) = run_app(config, cmd, debug, quiet, verbose) {
print!("{}", err.display_chain());
std::process::exit(-1);
}
Ok(())
}

View File

@ -1 +0,0 @@
../rustfmt.toml

View File

@ -1,2 +0,0 @@
*.db-shm
*.db-wal

View File

@ -13,9 +13,9 @@ publish = false
assert_cmd = "2"
log = "0.4"
mailin-embedded = { version = "0.7", features = ["rtls"] }
mailpot = { version = "^0.1", path = "../mailpot" }
mailpot = { version = "^0.1", path = "../core" }
predicates = "3"
stderrlog = { version = "^0.6" }
tempfile = { version = "3.9" }
stderrlog = "^0.5"
tempfile = "3.3"
[dev-dependencies]

View File

@ -1 +0,0 @@
../rustfmt.toml

View File

@ -1 +0,0 @@
../rustfmt.toml

View File

@ -1,83 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
//! Utils for templates with the [`minijinja`] crate.
pub use mailpot::StripCarets;
use super::*;
mod compressed;
mod filters;
mod objects;
pub use filters::*;
pub use objects::*;
lazy_static::lazy_static! {
pub static ref TEMPLATES: Environment<'static> = {
let mut env = Environment::new();
macro_rules! add {
(function $($id:ident),*$(,)?) => {
$(env.add_function(stringify!($id), $id);)*
};
(filter $($id:ident),*$(,)?) => {
$(env.add_filter(stringify!($id), $id);)*
}
}
add!(function calendarize,
strip_carets,
ensure_carets,
urlize,
url_encode,
heading,
topics,
login_path,
logout_path,
settings_path,
help_path,
list_path,
list_settings_path,
list_edit_path,
list_subscribers_path,
list_candidates_path,
list_post_path,
post_raw_path,
post_eml_path,
post_mbox_path
);
add!(filter pluralize);
// Load compressed templates. They are constructed in build.rs. See
// [ref:embed_templates]
let mut source = minijinja::Source::new();
for (name, bytes) in compressed::COMPRESSED {
let mut de_bytes = vec![];
zstd::stream::copy_decode(*bytes,&mut de_bytes).unwrap();
source.add_template(*name, String::from_utf8(de_bytes).unwrap()).unwrap();
}
env.set_source(source);
env.add_global("root_url_prefix", Value::from_safe_string( std::env::var("ROOT_URL_PREFIX").unwrap_or_default()));
env.add_global("public_url",Value::from_safe_string(std::env::var("PUBLIC_URL").unwrap_or_default()));
env.add_global("site_title", Value::from_safe_string(std::env::var("SITE_TITLE").unwrap_or_else(|_| "mailing list archive".to_string())));
env.add_global("site_subtitle", std::env::var("SITE_SUBTITLE").ok().map(Value::from_safe_string).unwrap_or_default());
env
};
}

Some files were not shown because too many files have changed in this diff Show More