mailpot/core/src/postfix.rs

679 lines
25 KiB
Rust

/*
* 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/>.
*/
//! Generate configuration for the postfix mail server.
//!
//! ## Transport maps (`transport_maps`)
//!
//! <http://www.postfix.org/postconf.5.html#transport_maps>
//!
//! ## Local recipient maps (`local_recipient_maps`)
//!
//! <http://www.postfix.org/postconf.5.html#local_recipient_maps>
//!
//! ## Relay domains (`relay_domains`)
//!
//! <http://www.postfix.org/postconf.5.html#relay_domains>
use std::{
borrow::Cow,
convert::TryInto,
fs::OpenOptions,
io::{BufWriter, Read, Seek, Write},
path::{Path, PathBuf},
};
use crate::{errors::*, Configuration, Connection, DbVal, MailingList, PostPolicy};
/*
transport_maps =
hash:/path-to-mailman/var/data/postfix_lmtp
local_recipient_maps =
hash:/path-to-mailman/var/data/postfix_lmtp
relay_domains =
hash:/path-to-mailman/var/data/postfix_domains
*/
/// Settings for generating postfix configuration.
///
/// See the struct methods for details.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PostfixConfiguration {
/// The UNIX username under which the mailpot process who processed incoming
/// mail is launched.
pub user: Cow<'static, str>,
/// The UNIX group under which the mailpot process who processed incoming
/// mail is launched.
pub group: Option<Cow<'static, str>>,
/// The absolute path of the `mailpot` binary.
pub binary_path: PathBuf,
/// The maximum number of `mailpot` processes to launch. Default is `1`.
#[serde(default)]
pub process_limit: Option<u64>,
/// The directory in which the map files are saved.
/// 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.
/// Default is `mailpot`.
///
/// A Postfix service is a daemon managed by the postfix process.
/// Each entry in the `master.cf` configuration file defines a single
/// service.
///
/// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html):
/// <https://www.postfix.org/master.5.html>.
#[serde(default)]
pub transport_name: Option<Cow<'static, str>>,
}
impl Default for PostfixConfiguration {
fn default() -> Self {
Self {
user: "user".into(),
group: None,
binary_path: Path::new("/usr/bin/mailpot").to_path_buf(),
process_limit: None,
map_output_path: None,
transport_name: None,
}
}
}
impl PostfixConfiguration {
/// Generate service line entry for Postfix's [`master.cf`](https://www.postfix.org/master.5.html) file.
pub fn generate_master_cf_entry(&self, config: &Configuration, config_path: &Path) -> String {
let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
format!(
"{transport_name} unix - n n - {process_limit} pipe
flags=RX user={username}{group_sep}{groupname} directory={{{data_dir}}} argv={{{binary_path}}} -c \
{{{config_path}}} post",
username = &self.user,
group_sep = if self.group.is_none() { "" } else { ":" },
groupname = self.group.as_deref().unwrap_or_default(),
process_limit = self.process_limit.unwrap_or(1),
binary_path = &self.binary_path.display(),
config_path = &config_path.display(),
data_dir = &config.data_path.display()
)
}
/// Generate `transport_maps` and `local_recipient_maps` for Postfix.
///
/// The output must be saved in a plain text file.
/// To make Postfix be able to read them, the `postmap` application must be
/// executed with the path to the map file as its sole argument.
/// `postmap` is usually distributed along with the other Postfix binaries.
pub fn generate_maps(
&self,
lists: &[(DbVal<MailingList>, Option<DbVal<PostPolicy>>)],
) -> String {
let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
let mut ret = String::new();
ret.push_str("# Automatically generated by mailpot.\n");
ret.push_str(
"# Upon its creation and every time it is modified, postmap(1) must be called for the \
changes to take effect:\n",
);
ret.push_str("# postmap /path/to/map_file\n\n");
// [ref:TODO]: add custom addresses if PostPolicy is custom
let calc_width = |list: &MailingList, policy: Option<&PostPolicy>| -> usize {
let addr = list.address.len();
match policy {
None => 0,
Some(PostPolicy { .. }) => addr + "+request".len(),
}
};
let Some(width): Option<usize> =
lists.iter().map(|(l, p)| calc_width(l, p.as_deref())).max()
else {
return ret;
};
for (list, policy) in lists {
macro_rules! push_addr {
($addr:expr) => {{
let addr = &$addr;
ret.push_str(addr);
for _ in 0..(width - addr.len() + 5) {
ret.push(' ');
}
ret.push_str(transport_name);
ret.push_str(":\n");
}};
}
match policy.as_deref() {
None => log::debug!(
"Not generating postfix map entry for list {} because it has no post_policy \
set.",
list.id
),
Some(PostPolicy { open: true, .. }) => {
push_addr!(list.address);
ret.push('\n');
}
Some(PostPolicy { .. }) => {
push_addr!(list.address);
push_addr!(list.subscription_mailto().address);
push_addr!(list.owner_mailto().address);
ret.push('\n');
}
}
}
// pop second of the last two newlines
ret.pop();
ret
}
/// Save service to Postfix's [`master.cf`](https://www.postfix.org/master.5.html) file.
///
/// If you wish to do it manually, get the text output from
/// [`PostfixConfiguration::generate_master_cf_entry`] and manually append it to the [`master.cf`](https://www.postfix.org/master.5.html) file.
///
/// If `master_cf_path` is `None`, the location of the file is assumed to be
/// `/etc/postfix/master.cf`.
pub fn save_master_cf_entry(
&self,
config: &Configuration,
config_path: &Path,
master_cf_path: Option<&Path>,
) -> Result<()> {
let new_entry = self.generate_master_cf_entry(config, config_path);
let path = master_cf_path.unwrap_or_else(|| Path::new("/etc/postfix/master.cf"));
// Create backup file.
let path_bkp = path.with_extension("cf.bkp");
std::fs::copy(path, &path_bkp).context(format!(
"Could not create master.cf backup {}",
path_bkp.display()
))?;
log::info!(
"Created backup of {} to {}.",
path.display(),
path_bkp.display()
);
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(false)
.open(path)
.context(format!("Could not open {}", path.display()))?;
let mut previous_content = String::new();
file.rewind()
.context(format!("Could not access {}", path.display()))?;
file.read_to_string(&mut previous_content)
.context(format!("Could not access {}", path.display()))?;
let original_size = previous_content.len();
let lines = previous_content.lines().collect::<Vec<&str>>();
let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
if let Some(line) = lines.iter().find(|l| l.starts_with(transport_name)) {
let pos = previous_content.find(line).ok_or_else(|| {
Error::from(ErrorKind::Bug("Unepected logical error.".to_string()))
})?;
let end_needle = " argv=";
let end_pos = previous_content[pos..]
.find(end_needle)
.and_then(|pos2| {
previous_content[(pos + pos2 + end_needle.len())..]
.find('\n')
.map(|p| p + pos + pos2 + end_needle.len())
})
.ok_or_else(|| {
Error::from(ErrorKind::Bug("Unepected logical error.".to_string()))
})?;
previous_content.replace_range(pos..end_pos, &new_entry);
} else {
previous_content.push_str(&new_entry);
previous_content.push('\n');
}
file.rewind()?;
if previous_content.len() < original_size {
file.set_len(
previous_content
.len()
.try_into()
.expect("Could not convert usize file size to u64"),
)?;
}
let mut file = BufWriter::new(file);
file.write_all(previous_content.as_bytes())
.context(format!("Could not access {}", path.display()))?;
file.flush()
.context(format!("Could not access {}", path.display()))?;
log::debug!("Saved new master.cf to {}.", path.display(),);
Ok(())
}
/// Generate `transport_maps` and `local_recipient_maps` for Postfix.
///
/// To succeed the user the command is running under must have write and
/// read access to `postfix_data_directory` and the `postmap` binary
/// must be discoverable in your `PATH` environment variable.
///
/// `postmap` is usually distributed along with the other Postfix binaries.
pub fn save_maps(&self, config: &Configuration) -> Result<()> {
let db = Connection::open_db(config.clone())?;
let Some(postmap) = find_binary_in_path("postmap") else {
return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
"Could not find postmap binary in PATH.",
))));
};
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 content = self.generate_maps(&lists_post_policies);
let path = self
.map_output_path
.as_deref()
.unwrap_or(&config.data_path)
.join("mailpot_postfix_map");
let mut file = BufWriter::new(
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)
.context(format!("Could not open {}", path.display()))?,
);
file.write_all(content.as_bytes())
.context(format!("Could not write to {}", path.display()))?;
file.flush()
.context(format!("Could not write to {}", path.display()))?;
let output = std::process::Command::new("sh")
.arg("-c")
.arg(&format!("{} {}", postmap.display(), path.display()))
.output()
.with_context(|| {
format!(
"Could not execute `postmap` binary in path {}",
postmap.display()
)
})?;
if !output.status.success() {
use std::os::unix::process::ExitStatusExt;
if let Some(code) = output.status.code() {
return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
format!(
"{} exited with {}.\nstderr was:\n---{}---\nstdout was\n---{}---\n",
code,
postmap.display(),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
),
))));
} else if let Some(signum) = output.status.signal() {
return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
format!(
"{} was killed with signal {}.\nstderr was:\n---{}---\nstdout \
was\n---{}---\n",
signum,
postmap.display(),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
),
))));
} else {
return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
format!(
"{} failed for unknown reason.\nstderr was:\n---{}---\nstdout \
was\n---{}---\n",
postmap.display(),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
),
))));
}
}
Ok(())
}
}
fn find_binary_in_path(binary_name: &str) -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths).find_map(|dir| {
let full_path = dir.join(binary_name);
if full_path.is_file() {
Some(full_path)
} else {
None
}
})
})
}
#[test]
fn test_postfix_generation() -> Result<()> {
use tempfile::TempDir;
use crate::*;
mailpot_tests::init_stderr_logging();
fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
use melib::smtp::*;
SmtpServerConf {
hostname: "127.0.0.1".into(),
port: 1025,
envelope_from: "foo-chat@example.com".into(),
auth: SmtpAuth::None,
security: SmtpSecurity::None,
extensions: Default::default(),
}
}
let tmp_dir = TempDir::new()?;
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::Smtp(get_smtp_conf()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};
let config_path = tmp_dir.path().join("conf.toml");
{
let mut conf = OpenOptions::new()
.write(true)
.create(true)
.open(&config_path)?;
conf.write_all(config.to_toml().as_bytes())?;
conf.flush()?;
}
let db = Connection::open_or_create_db(config)?.trusted();
assert!(db.lists()?.is_empty());
// Create three lists:
//
// - One without any policy, which should not show up in postfix maps.
// - One with subscriptions disabled, which would only add the list address in
// postfix maps.
// - One with subscriptions enabled, which should add all addresses (list,
// list+{un,}subscribe, etc).
let first = db.create_list(MailingList {
pk: 0,
name: "first".into(),
id: "first".into(),
address: "first@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})?;
assert_eq!(first.pk(), 1);
let second = db.create_list(MailingList {
pk: 0,
name: "second".into(),
id: "second".into(),
address: "second@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})?;
assert_eq!(second.pk(), 2);
let post_policy = db.set_list_post_policy(PostPolicy {
pk: 0,
list: second.pk(),
announce_only: false,
subscription_only: false,
approval_needed: false,
open: true,
custom: false,
})?;
assert_eq!(post_policy.pk(), 1);
let third = db.create_list(MailingList {
pk: 0,
name: "third".into(),
id: "third".into(),
address: "third@example.com".into(),
description: None,
topics: vec![],
archive_url: None,
})?;
assert_eq!(third.pk(), 3);
let post_policy = db.set_list_post_policy(PostPolicy {
pk: 0,
list: third.pk(),
announce_only: false,
subscription_only: false,
approval_needed: true,
open: false,
custom: false,
})?;
assert_eq!(post_policy.pk(), 2);
let mut postfix_conf = PostfixConfiguration::default();
let expected_mastercf_entry = format!(
"mailpot unix - n n - 1 pipe
flags=RX user={} directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
&postfix_conf.user,
tmp_dir.path().display(),
config_path.display()
);
assert_eq!(
expected_mastercf_entry.trim_end(),
postfix_conf.generate_master_cf_entry(db.conf(), &config_path)
);
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 = postfix_conf.generate_maps(&lists_post_policies);
let expected = "second@example.com mailpot:
third@example.com mailpot:
third+request@example.com mailpot:
third+owner@example.com mailpot:
";
assert!(
maps.ends_with(expected),
"maps has unexpected contents: has\n{:?}\nand should have ended with\n{:?}",
maps,
expected
);
let master_edit_value = r#"#
# Postfix master process configuration file. For details on the format
# of the file, see the master(5) manual page (command: "man 5 master" or
# on-line: http://www.postfix.org/master.5.html).
#
# Do not forget to execute "postfix reload" after editing this file.
#
# ==========================================================================
# service type private unpriv chroot wakeup maxproc command + args
# (yes) (yes) (no) (never) (100)
# ==========================================================================
smtp inet n - y - - smtpd
pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup
qmgr unix n - n 300 1 qmgr
#qmgr unix n - n 300 1 oqmgr
tlsmgr unix - - y 1000? 1 tlsmgr
rewrite unix - - y - - trivial-rewrite
bounce unix - - y - 0 bounce
defer unix - - y - 0 bounce
trace unix - - y - 0 bounce
verify unix - - y - 1 verify
flush unix n - y 1000? 0 flush
proxymap unix - - n - - proxymap
proxywrite unix - - n - 1 proxymap
smtp unix - - y - - smtp
relay unix - - y - - smtp
-o syslog_name=postfix/$service_name
showq unix n - y - - showq
error unix - - y - - error
retry unix - - y - - error
discard unix - - y - - discard
local unix - n n - - local
virtual unix - n n - - virtual
lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil
scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd
maildrop unix - n n - - pipe
flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
uucp unix - n n - - pipe
flags=Fqhu user=uucp argv=uux -r -n -z -a$sender - $nexthop!rmail ($recipient)
#
# Other external delivery methods.
#
ifmail unix - n n - - pipe
flags=F user=ftn argv=/usr/lib/ifmail/ifmail -r $nexthop ($recipient)
bsmtp unix - n n - - pipe
flags=Fq. user=bsmtp argv=/usr/lib/bsmtp/bsmtp -t$nexthop -f$sender $recipient
scalemail-backend unix - n n - 2 pipe
flags=R user=scalemail argv=/usr/lib/scalemail/bin/scalemail-store ${nexthop} ${user} ${extension}
mailman unix - n n - - pipe
flags=FRX user=list argv=/usr/lib/mailman/bin/postfix-to-mailman.py ${nexthop} ${user}
"#;
let path = tmp_dir.path().join("master.cf");
{
let mut mastercf = OpenOptions::new().write(true).create(true).open(&path)?;
mastercf.write_all(master_edit_value.as_bytes())?;
mastercf.flush()?;
}
postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
let mut first = String::new();
{
let mut mastercf = OpenOptions::new()
.write(false)
.read(true)
.create(false)
.open(&path)?;
mastercf.read_to_string(&mut first)?;
}
assert!(
first.ends_with(&expected_mastercf_entry),
"edited master.cf has unexpected contents: has\n{:?}\nand should have ended with\n{:?}",
first,
expected_mastercf_entry
);
// test that a smaller entry can be successfully replaced
postfix_conf.user = "nobody".into();
postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
let mut second = String::new();
{
let mut mastercf = OpenOptions::new()
.write(false)
.read(true)
.create(false)
.open(&path)?;
mastercf.read_to_string(&mut second)?;
}
let expected_mastercf_entry = format!(
"mailpot unix - n n - 1 pipe
flags=RX user=nobody directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
tmp_dir.path().display(),
config_path.display()
);
assert!(
second.ends_with(&expected_mastercf_entry),
"doubly edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
with\n{:?}",
second,
expected_mastercf_entry
);
// test that a larger entry can be successfully replaced
postfix_conf.user = "hackerman".into();
postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
let mut third = String::new();
{
let mut mastercf = OpenOptions::new()
.write(false)
.read(true)
.create(false)
.open(&path)?;
mastercf.read_to_string(&mut third)?;
}
let expected_mastercf_entry = format!(
"mailpot unix - n n - 1 pipe
flags=RX user=hackerman directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
tmp_dir.path().display(),
config_path.display(),
);
assert!(
third.ends_with(&expected_mastercf_entry),
"triply edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
with\n{:?}",
third,
expected_mastercf_entry
);
// test that if groupname is given it is rendered correctly.
postfix_conf.group = Some("nobody".into());
postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
let mut fourth = String::new();
{
let mut mastercf = OpenOptions::new()
.write(false)
.read(true)
.create(false)
.open(&path)?;
mastercf.read_to_string(&mut fourth)?;
}
let expected_mastercf_entry = format!(
"mailpot unix - n n - 1 pipe
flags=RX user=hackerman:nobody directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
tmp_dir.path().display(),
config_path.display(),
);
assert!(
fourth.ends_with(&expected_mastercf_entry),
"fourthly edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
with\n{:?}",
fourth,
expected_mastercf_entry
);
Ok(())
}