>(String::from("Mailaddress {addr} not parsable.").into())?,
+ ));
+ }
+ let unsortable_mail = EmailAddress::new(&addr, &default_domain.to_string(), None)?;
+ Ok(OrdEmailAddress(unsortable_mail))
+}
+
+// The output is wrapped in a Result to allow matching on errors.
+// Returns an Iterator to the Reader of the lines of the file.
+pub fn read_lines(filename: P) -> io::Result>>
+where
+ P: AsRef,
+{
+ let file = File::open(filename)?;
+ Ok(io::BufReader::new(file).lines())
+}
+
+/// Generate a Sieve script
+/// from a map of destination addresses to a list of their forwarding addresses.
+///
+/// Addresses are sorted according to the order on `OrdEmailAddress`.
+pub fn generate_sieve_script(redirects: AliasMap) -> String {
+ let mut script: String =
+ "require [\"variables\", \"copy\", \"vnd.stalwart.expressions\", \"envelope\", \"editheader\"];
+
+let \"i\" \"0\";
+while \"i < count(envelope.to)\" {
+ let \"redirected\" \"false\";
+"
+ .to_string();
+ for (redirect, mut destinations) in redirects {
+ script += format!(
+ // inspired by https://github.com/stalwartlabs/mail-server/issues/916#issuecomment-2474844389
+ " if eval \"eq_ignore_case(envelope.to[i], '{}')\" {{
+ addheader \"Delivered-To\" \"{}\";
+{}
+ deleteheader :index 1 :is \"Delivered-To\" \"{}\";
+ let \"redirected\" \"true\";
+ }}
+",
+ redirect.0,
+ redirect.0,
+ {
+ let mut subscript: String = String::new();
+ destinations.sort();
+ for destination in destinations.iter() {
+ subscript += format!(" redirect :copy \"{}\";\n", destination.0).as_str();
+ }
+ subscript
+ },
+ redirect.0
+ )
+ .as_str();
+ }
+ script += " if eval \"!redirected\" {
+ let \"destination\" \"envelope.to[i]\";
+ redirect :copy \"${destination}\";
+ }
+";
+ script += " let \"i\" \"i+1\";\n";
+ script += "}
+discard;";
+ script
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::str::FromStr;
+
+ #[test]
+ fn recursion_detection() {
+ let result = parse_alias_to_map(vec![AliasFile {
+ content: read_lines("testdata/infiniterec.aliases").unwrap(),
+ default_domain: FQDN::from_str("example.com").unwrap(),
+ }]);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn basic_parsing() {
+ let result = parse_alias_to_map(vec![AliasFile {
+ content: read_lines("testdata/simple.aliases").unwrap(),
+ default_domain: FQDN::from_str("example.com").unwrap(),
+ }])
+ .unwrap();
+ assert_eq!(result.len(), 4);
+
+ for redirects in result.iter() {
+ assert_eq!(redirects.1[0].0.to_string(), "me@example.org");
+ }
+ }
+}
diff --git a/packages/alias-to-sieve/src/main.rs b/packages/alias-to-sieve/src/main.rs
index cd31b7a..82b2cd3 100644
--- a/packages/alias-to-sieve/src/main.rs
+++ b/packages/alias-to-sieve/src/main.rs
@@ -1,74 +1,34 @@
-use std::io::{self};
-use std::collections::HashMap;
+use alias_to_sieve::*;
+use fqdn::FQDN;
+use std::env;
+use std::str::FromStr;
fn main() {
- let redirects = parse_alias_to_hashmap();
- let sieve_script = generate_sieve_script(redirects);
- println!("{}", sieve_script);
+ let args: Vec = 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 = 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).unwrap())
+ );
}
-fn generate_sieve_script(redirects: HashMap>) -> String {
- let mut script : String = "require [\"envelope\", \"copy\"];\n".to_string();
- for (redirect, destinations) in redirects {
- script += format!("if envelope :is \"to\" \"{}\" {{\n{}}}\n", redirect,
- (|| {
- let mut subscript : String = "".to_string();
- for destination in destinations.iter().rev().skip(1).rev() {
- subscript += format!(" redirect :copy \"{}\";\n", destination).as_str();
- }
- subscript += format!(" redirect \"{}\";\n", destinations.iter().rev().next().unwrap()).as_str();
- return subscript;
- })()
- ).as_str();
- }
- return script;
-}
-
-fn parse_alias_to_hashmap() -> HashMap> {
- // File must exist in the current path
- let mut redirect_map : HashMap> = HashMap::new();
- let mut destinations : Vec = Vec::new();
- for line in io::stdin().lines() {
- let line = line.unwrap();
- 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 == "" {
- continue;
- }
- let redirects: Vec = line.split_at(line.find(char::is_whitespace).unwrap_or(0)).1.split(" ")
- .filter(|address| address.trim().to_string().replace(",","") != "").map(|address| to_mailaddress(address)).collect();
- if redirects.len() == 0 {
- continue;
- }
- destinations.push(to_mailaddress(destination));
- redirect_map.insert(to_mailaddress(destination), redirects);
- }
- let mut changed = true;
- while changed {
- changed = false;
- let mut all_new_redirects : HashMap> = HashMap::new();
- for destination in destinations.iter() {
- for forward_to in redirect_map.get(destination).unwrap().iter() {
- 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().map(|x| x.clone())));
- }
- }
- }
- for (destination, new_redirect) in all_new_redirects {
- *redirect_map.get_mut(&destination).unwrap() = new_redirect;
- }
- }
- return redirect_map;
-}
-
-fn to_mailaddress(local_part: &str) -> String {
- let mut addr = local_part.trim().to_string();
- addr = addr.replace(",", "");
- if addr.contains("@") {
- return addr;
- }
- return addr + "@mathebau.de";
+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"
+ );
}
diff --git a/packages/alias-to-sieve/testdata/infiniterec.aliases b/packages/alias-to-sieve/testdata/infiniterec.aliases
new file mode 100644
index 0000000..1ac500e
--- /dev/null
+++ b/packages/alias-to-sieve/testdata/infiniterec.aliases
@@ -0,0 +1,2 @@
+# This creates an infinite recursion
+orga orga
diff --git a/packages/alias-to-sieve/testdata/simple.aliases b/packages/alias-to-sieve/testdata/simple.aliases
new file mode 100644
index 0000000..690f620
--- /dev/null
+++ b/packages/alias-to-sieve/testdata/simple.aliases
@@ -0,0 +1,4 @@
+admin root
+sudo root
+postmaster admin
+root me@example.org
diff --git a/packages/flake-module.nix b/packages/flake-module.nix
new file mode 100644
index 0000000..2ffe2c2
--- /dev/null
+++ b/packages/flake-module.nix
@@ -0,0 +1,29 @@
+{inputs, ...}: {
+ perSystem = {
+ pkgs,
+ system,
+ self',
+ ...
+ }: {
+ # We build the alias to sieve tool here, this is a two step process, we first generate
+ # a nix expression with the crate2nix tool from nixpkgs that directly calls rustc
+ # and thus bypasses cargo. We could also generate this file by hand and version it,
+ # but that would need regeneration every time some dependency is updated, so we
+ # opt for build time generation. Afterwards we import and evaluate the generated
+ # nix expression to build the package. For the explicit solution see:
+ # https://nix-community.github.io/crate2nix/00_guides/30_generating/
+ # and for the documentation to this one
+ # https://nix-community.github.io/crate2nix/00_guides/31_auto_generating/
+ # as this module uses idf and forces builds during the evaluation it might
+ # have drastic performance implications, see
+ # https://nix.dev/manual/nix/2.24/language/import-from-derivation
+ packages.alias-to-sieve = let
+ alias-to-sieve-nix = inputs.crate2nix.tools.${system}.generatedCargoNix {
+ name = "alias-to-sieve";
+ src = ./alias-to-sieve;
+ };
+ in
+ (pkgs.callPackage alias-to-sieve-nix {}).rootCrate.build;
+ checks.alias-to-sieve = self'.packages.alias-to-sieve.override {runTests = true;};
+ };
+}