use std::{
fmt::Write as _,
net::SocketAddr,
os::unix,
path::{Path, PathBuf},
};
use lettre::transport::smtp::response::Response;
use miette::{miette, Context, IntoDiagnostic, Result};
use tokio::{
fs::{self, create_dir_all, remove_dir_all, write, File},
io::AsyncWriteExt,
process::Command,
};
use walkdir::WalkDir;
use crate::utils::PortGuard;
pub struct PostfixInstance {
name: String,
port: u16,
milter_addr: SocketAddr,
config_dir: PathBuf,
spool_dir: PathBuf,
data_dir: PathBuf,
}
impl PostfixInstance {
pub async fn setup(name: &str, milter_addr: SocketAddr) -> Result<PostfixInstance> {
let name = format!("postfix-{name}");
let port = PortGuard::port()
.await
.wrap_err("Failed to retrieve smtp port")?;
println!("Setup '{name}' on :{port}");
let config_dir = PathBuf::from(format!("/etc/{name}"));
let spool_dir = PathBuf::from(format!("/var/spool/{name}"));
let data_dir = PathBuf::from(format!("/var/lib/{name}"));
let mut instance = Self {
name,
port,
milter_addr,
config_dir,
spool_dir,
data_dir,
};
let pid_file = instance.spool_dir.join("pid/master.pid");
if pid_file.is_file() {
let pid = fs::read_to_string(&pid_file)
.await
.into_diagnostic()
.wrap_err("Failed reading pid file")?;
let proc_pid = PathBuf::from_iter(["/proc", pid.trim()]);
if proc_pid.exists() {
println!("Stopping running instance with pid '{}'", pid.trim());
instance.stop().await?;
}
}
let log_file = instance.log_file_name();
File::options()
.truncate(true)
.write(true)
.create(true)
.open(log_file)
.await
.into_diagnostic()
.wrap_err("Failed creating or truncating logfile")?;
if instance.spool_dir.is_dir() {
remove_dir_all(&instance.spool_dir)
.await
.into_diagnostic()
.wrap_err("Failed removing spool dir")?;
}
if instance.data_dir.is_dir() {
remove_dir_all(&instance.data_dir)
.await
.into_diagnostic()
.wrap_err("Failed removing data dir")?;
}
instance.create_dirs_and_config().await?;
status(
"postfix",
&["-c", &instance.config_dir.display().to_string(), "start"],
)
.await
.wrap_err("Failed running 'postfix start'")?;
Ok(instance)
}
fn log_file_name(&self) -> String {
format!("/var/log/mail-{}.log", self.name)
}
pub async fn log_file_content(&self) -> Result<String> {
fs::read_to_string(self.log_file_name())
.await
.into_diagnostic()
.wrap_err("Failed to read postfix log file")
}
pub fn port(&self) -> u16 {
self.port
}
async fn create_dirs_and_config(&mut self) -> Result<()> {
create_and_own(&self.config_dir, 0, 0)
.await
.wrap_err("Failed config dir setup")?;
create_and_own(&self.spool_dir, 0, 0)
.await
.wrap_err("Failed spool dir setup")?;
create_and_own(&self.data_dir, 100, 103)
.await
.wrap_err("Failed data dir setup")?;
self.render_cf("main", |mut main_cf| {
main_cf = main_cf.replace("/var/log/mail.log", &self.log_file_name());
let _ = writeln!(main_cf, "data_directory = {}", &self.data_dir.display());
let _ = writeln!(main_cf, "queue_directory = {}", &self.spool_dir.display());
main_cf = main_cf.replace(
"transport_maps = hash:/etc/postfix/transport",
&format!(
"transport_maps = hash:{}/transport",
&self.config_dir.display()
),
);
main_cf = main_cf.replace(
"smtpd_milters = inet:127.0.0.1:8080",
&format!("smtpd_milters = inet:{}", self.milter_addr),
);
main_cf
})
.await
.wrap_err("Failed to configure postfix instance")?;
self.render_cf("master", |mut main_cf| {
main_cf = main_cf.replace(
"smtp inet n - y - - smtpd",
"#smtp inet n - y - - smtpd",
);
let _ = writeln!(
main_cf,
"{} inet n - y - - smtpd",
self.port
);
main_cf
})
.await
.wrap_err("Failed updating instance master.cf")?;
write(self.config_dir.join("transport"), "* smtp:127.0.0.1:2525\n")
.await
.into_diagnostic()
.wrap_err("Failed to create transport map")?;
let cmd = Command::new("postmap")
.args(["-c", &self.config_dir.display().to_string(), "./transport"])
.current_dir(&self.config_dir)
.status()
.await
.into_diagnostic()?;
if !cmd.success() {
return Err(miette!("'postmap' failed to generate transport table"));
}
Ok(())
}
async fn render_cf(&self, config: &str, updater: impl Fn(String) -> String) -> Result<()> {
let cf_in_path = format!("/etc/postfix/{config}.cf");
let mut cf_content = fs::read_to_string(&cf_in_path)
.await
.into_diagnostic()
.wrap_err("Failed to read in main.cf for instance")?;
cf_content = updater(cf_content);
let cf_out_path = self.config_dir.join(config).with_extension("cf");
File::options()
.truncate(true)
.create(true)
.write(true)
.open(&cf_out_path)
.await
.into_diagnostic()
.wrap_err("Failed open .cf file to write back config")?
.write_all(cf_content.as_bytes())
.await
.into_diagnostic()
.wrap_err("Failed to update .cf conf")?;
Ok(())
}
pub async fn stop(&self) -> Result<()> {
status(
"postfix",
&["-c", &self.config_dir.display().to_string(), "stop"],
)
.await
.wrap_err("Failed running 'postfix stop'")?;
Ok(())
}
pub async fn get_mail_content(&self, response: &Response) -> Result<String> {
let id = response_to_id(response)?;
let message_file = self.active_or_defered(id)?;
let content = Command::new("postcat")
.arg(message_file.display().to_string())
.output()
.await
.into_diagnostic()
.wrap_err("Failed postcat-ing message file")?
.stdout;
let content = String::from_utf8_lossy(&content).to_string();
Ok(content)
}
fn active_or_defered(&self, id: &str) -> Result<PathBuf> {
for typ in ["active", "deferred"] {
let current_dir = self.spool_dir.join(typ);
for entry in WalkDir::new(current_dir)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.metadata().unwrap().is_file())
{
let path = entry.path();
if let Some(name) = path.file_name() {
if name.to_string_lossy() == id {
return Ok(path.to_path_buf());
}
}
}
}
Err(miette!("The message was not active or deferred by postfix"))
}
}
async fn create_and_own(path: impl AsRef<Path>, pid: u32, gid: u32) -> Result<()> {
create_dir_all(path.as_ref())
.await
.into_diagnostic()
.wrap_err("Failed creating directory")?;
unix::fs::chown(path, Some(pid), Some(gid))
.into_diagnostic()
.wrap_err("Failed owning created directory")?;
Ok(())
}
async fn status(cmd: &str, args: &[&str]) -> Result<()> {
let output = Command::new(cmd)
.args(args)
.output()
.await
.into_diagnostic()?;
if !output.status.success() {
return Err(miette!(
"'{} {}' returned {}:\nStderr:\n{}Stdout:\n{}",
cmd,
args.join(" "),
output.status,
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
));
}
Ok(())
}
fn response_to_id(response: &Response) -> Result<&str> {
if !response.is_positive() {
return Err(miette!(
help = "Only mails which where succesfully sent can be content checked",
"Provided response was not positive"
));
}
let id = response
.first_line()
.ok_or(miette!(
"The provided response did not contain id information"
))?
.split_ascii_whitespace()
.last()
.ok_or(miette!(
"The provided response did not contain a postfix id"
))?;
println!("Postifx id: {id}");
Ok(id)
}