nixConfig/packages/alias-to-sieve/src/main.rs
2025-03-27 02:14:34 +01:00

174 lines
6.3 KiB
Rust

use email_address_parser::EmailAddress;
use fqdn::FQDN;
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::env;
use std::fs::File;
use std::io::{self, BufRead};
use std::path::Path;
use std::str::FromStr;
struct AliasFile {
content: io::Lines<io::BufReader<File>>,
default_domain: FQDN,
}
#[derive(PartialEq, Eq, Clone)]
struct OrdEmailAddress(EmailAddress);
impl PartialOrd for OrdEmailAddress {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.0.to_string().cmp(&other.0.to_string()))
}
}
impl Ord for OrdEmailAddress {
fn cmp(&self, other: &Self) -> Ordering {
self.0.to_string().cmp(&other.0.to_string())
}
}
type AliasMap = BTreeMap<OrdEmailAddress, Vec<OrdEmailAddress>>;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 || args.len() % 2 == 0 {
print_help();
return;
}
// Collect alias files and their default domains
let mut alias_files: Vec<AliasFile> = Vec::new();
for i in (1..args.len()).step_by(2) {
if let Ok(lines) = read_lines(&args[i]) {
alias_files.push(AliasFile {
content: lines,
default_domain: FQDN::from_str(&args[i + 1]).unwrap(),
});
}
}
println!("{}", generate_sieve_script(parse_alias_to_map(alias_files)));
}
/// Read a virtual alias file <https://www.postfix.org/virtual.5.html>
/// and convert it to a map of destination addresses to a list of their final forwarding addresses.
fn parse_alias_to_map(alias_files: Vec<AliasFile>) -> AliasMap {
// File must exist in the current path
let mut redirect_map: AliasMap = AliasMap::new();
let mut destinations: Vec<OrdEmailAddress> = Vec::new();
// Extract all pairs (destination to redirect addresses) from the alias files
for alias_file in alias_files {
for line in alias_file.content {
let line = line.unwrap();
// Ignore comments in the alias file
let line = String::from(line.split_at(line.find('#').unwrap_or(line.len())).0);
let destination = line.split_at(line.find(char::is_whitespace).unwrap_or(0)).0;
if destination.is_empty() {
continue;
}
let redirects: Vec<OrdEmailAddress> = line
.split_at(line.find(char::is_whitespace).unwrap_or(0))
.1
.split(' ')
.filter(|address| !address.trim().to_string().replace(',', "").is_empty())
.map(|addr| to_mailaddress(addr, &alias_file.default_domain))
.collect();
if redirects.is_empty() {
continue;
}
destinations.push(to_mailaddress(destination, &alias_file.default_domain));
redirect_map.insert(
to_mailaddress(destination, &alias_file.default_domain),
redirects,
);
}
}
// Replace redirects that are again forwarded elsewhere by that.
// Break after depth max_iterations and assume infinite recursion afterwards.
let mut changed = true;
let mut iterations = 0;
let max_iterations = 100;
while changed && iterations < max_iterations {
changed = false;
iterations += 1;
let mut all_new_redirects: AliasMap = AliasMap::new();
for destination in &destinations {
for forward_to in redirect_map.get(destination).unwrap() {
if let Some(new_redirects) = redirect_map.get(forward_to) {
changed = true;
all_new_redirects
.entry(destination.clone())
.or_insert(redirect_map.get(destination).unwrap().clone())
.retain(|dest| *dest != *forward_to);
all_new_redirects
.entry(destination.clone())
.and_modify(|d| d.extend(new_redirects.iter().cloned()));
}
}
}
for (destination, new_redirect) in all_new_redirects {
*redirect_map.get_mut(&destination).unwrap() = new_redirect;
}
}
assert!(iterations != max_iterations, "Possibly infinite recursion detected in parse_alias_map. Did not terminate after {max_iterations} rounds.");
redirect_map
}
/// Generate a Sieve script <https://en.wikipedia.org/wiki/Sieve_(mail_filtering_language)>
/// from a map of destination addresses to a list of their forwarding addresses.
///
/// Addresses are sorted according to the order on `OrdEmailAddress`.
fn generate_sieve_script(redirects: AliasMap) -> String {
let mut script: String = "require [\"envelope\", \"copy\"];\n\n".to_string();
for (redirect, mut destinations) in redirects {
script += format!("if envelope :is \"to\" \"{}\" {{\n{}}}\n", redirect.0, {
let mut subscript: String = String::new();
destinations.sort();
for destination in destinations.iter().rev().skip(1).rev() {
subscript += format!(" redirect :copy \"{}\";\n", destination.0).as_str();
}
subscript
+ format!(
" redirect \"{}\";\n",
destinations.iter().next_back().unwrap().0
)
.as_str()
})
.as_str();
}
script
}
/// Create an `OrdEmailAddress` from some alias entry.
/// Return parameter for complete mail addresses and append the default domain for local parts.
fn to_mailaddress(alias_entry: &str, default_domain: &FQDN) -> OrdEmailAddress {
let mut addr = alias_entry.trim().to_string();
addr = addr.replace(',', "");
if addr.contains('@') {
return OrdEmailAddress(EmailAddress::parse(&addr, None).unwrap());
}
OrdEmailAddress(EmailAddress::new(&addr, &default_domain.to_string(), None).unwrap())
}
// The output is wrapped in a Result to allow matching on errors.
// Returns an Iterator to the Reader of the lines of the file.
fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
where
P: AsRef<Path>,
{
let file = File::open(filename)?;
Ok(io::BufReader::new(file).lines())
}
fn print_help() {
print!(
"Reads a virtual alias file and needs a default domain to append to local paths, e.g.
./alias_to_sieve example.com.txt example.com"
);
}