>(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
new file mode 100644
index 0000000..82b2cd3
--- /dev/null
+++ b/packages/alias_to_sieve/src/main.rs
@@ -0,0 +1,34 @@
+use alias_to_sieve::*;
+use fqdn::FQDN;
+use std::env;
+use std::str::FromStr;
+
+fn main() {
+ 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 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/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;};
+ };
+}