diff --git a/packages/alias-to-sieve/Cargo.toml b/packages/alias-to-sieve/Cargo.toml index 77b7e28..488dab9 100644 --- a/packages/alias-to-sieve/Cargo.toml +++ b/packages/alias-to-sieve/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2021" rust-version = "1.68.2" +description = "Convert an alias file to a sieve script for stalwart-mail" +readme = "README.md" +license = " AGPL-3.0-only" +keywords = ["mail", "sieve", "alias", "stalwart"] [dependencies] fqdn = {version = "0.4.2", features = ["domain-label-length-limited-to-63", "domain-name-without-special-chars"]} diff --git a/packages/alias-to-sieve/README.md b/packages/alias-to-sieve/README.md new file mode 100644 index 0000000..339219c --- /dev/null +++ b/packages/alias-to-sieve/README.md @@ -0,0 +1,60 @@ +This script converts an alias file to a sieve script for [stalwart-mail](https://stalw.art/). + +## Usage +Given an alias file `testdata/simple.aliases` that contains lines of redirects of the form localpart with optional `@fqdn` followed by a space followed by a list (space or comma+space separated) list of destinations that consist of a localpart and optionally an `@fqdn`. +If you don't define a fqdn along any of the addresses, the default domain from your commandline input will be appended. + +An example using the testdata directory of this repository: +``` +$ ./alias_to_sieve testdata/simple.aliases example.com +require ["variables", "copy", "vnd.stalwart.expressions", "envelope", "editheader"]; + +let "i" "0"; +while "i < count(envelope.to)" { + let "redirected" "false"; + if eval "eq_ignore_case(envelope.to[i], 'admin@example.com')" { + addheader "Delivered-To" "admin@example.com"; + redirect :copy "me@example.org"; + + deleteheader :index 1 :is "Delivered-To" "admin@example.com"; + let "redirected" "true"; + } + if eval "eq_ignore_case(envelope.to[i], 'postmaster@example.com')" { + addheader "Delivered-To" "postmaster@example.com"; + redirect :copy "me@example.org"; + + deleteheader :index 1 :is "Delivered-To" "postmaster@example.com"; + let "redirected" "true"; + } + if eval "eq_ignore_case(envelope.to[i], 'root@example.com')" { + addheader "Delivered-To" "root@example.com"; + redirect :copy "me@example.org"; + + deleteheader :index 1 :is "Delivered-To" "root@example.com"; + let "redirected" "true"; + } + if eval "eq_ignore_case(envelope.to[i], 'sudo@example.com')" { + addheader "Delivered-To" "sudo@example.com"; + redirect :copy "me@example.org"; + + deleteheader :index 1 :is "Delivered-To" "sudo@example.com"; + let "redirected" "true"; + } + if eval "!redirected" { + let "destination" "envelope.to[i]"; + redirect :copy "${destination}"; + } + let "i" "i+1"; +} +discard; + +``` + +## Limitations +You cannot use apostrophes (') in any mail addresses although allowed by [RFC 5322](https://www.rfc-editor.org/rfc/rfc5322) since they would break termination of strings in sieve. + +This parser is not designed with security in mind. While the above gives some basic protection against code injection, I have no idea whether sieve has other pitfalls that might allow them. + +This is my first rust project, consume the code with care. + +The generated code is specific to stalwart-mail and contains non-standard sieve features. diff --git a/packages/alias-to-sieve/src/lib.rs b/packages/alias-to-sieve/src/lib.rs index 35ebea2..c0d6842 100644 --- a/packages/alias-to-sieve/src/lib.rs +++ b/packages/alias-to-sieve/src/lib.rs @@ -12,29 +12,59 @@ pub struct AliasFile { pub default_domain: FQDN, } -#[derive(PartialEq, Eq, Clone)] -pub struct OrdEmailAddress(EmailAddress); +#[derive(PartialEq, Eq, Clone, Debug)] +pub struct AliasEmailAddress(EmailAddress); -impl PartialOrd for OrdEmailAddress { +impl AliasEmailAddress { + /// Create an `AliasEmailAddress` from some alias entry. + /// Return parameter for complete mail addresses and append the default domain for local parts. + pub fn new( + alias_entry: &str, + default_domain: &FQDN, + ) -> Result> { + let mut addr = alias_entry.trim().to_string(); + addr = addr.replace(',', ""); + + // The domain already fails on instantiation of the FQDN type if it contains an apostrophe. + if addr.contains('\'') { + return Err(format!( + "Mailaddress {addr} contains an apostrophe which breaks the script generation." + ) + .into()); + } + + if addr.contains('@') { + return Ok(AliasEmailAddress( + EmailAddress::parse(&addr, None).ok_or::>( + String::from("Mailaddress {addr} not parsable.").into(), + )?, + )); + } + let unsortable_mail = EmailAddress::new(&addr, &default_domain.to_string(), None)?; + Ok(AliasEmailAddress(unsortable_mail)) + } +} + +impl PartialOrd for AliasEmailAddress { fn partial_cmp(&self, other: &Self) -> Option { Some(self.0.to_string().cmp(&other.0.to_string())) } } -impl Ord for OrdEmailAddress { +impl Ord for AliasEmailAddress { fn cmp(&self, other: &Self) -> Ordering { self.0.to_string().cmp(&other.0.to_string()) } } -pub type AliasMap = BTreeMap>; +pub type AliasMap = BTreeMap>; /// Read a virtual alias file /// and convert it to a map of destination addresses to a list of their final forwarding addresses. pub fn parse_alias_to_map(alias_files: Vec) -> Result> { // File must exist in the current path let mut redirect_map: AliasMap = AliasMap::new(); - let mut destinations: Vec = Vec::new(); + let mut destinations: Vec = Vec::new(); // Extract all pairs (destination to redirect addresses) from the alias files for alias_file in alias_files { @@ -48,20 +78,23 @@ pub fn parse_alias_to_map(alias_files: Vec) -> Result = line + let redirects: Vec = 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)) + .map(|addr| AliasEmailAddress::new(addr, &alias_file.default_domain)) .collect::, _>>()?; if redirects.is_empty() { continue; } - destinations.push(to_mailaddress(destination, &alias_file.default_domain)?); + destinations.push(AliasEmailAddress::new( + destination, + &alias_file.default_domain, + )?); redirect_map.insert( - to_mailaddress(destination, &alias_file.default_domain)?, + AliasEmailAddress::new(destination, &alias_file.default_domain)?, redirects, ); } @@ -95,29 +128,11 @@ pub fn parse_alias_to_map(alias_files: Vec) -> Result Result> { - let mut addr = alias_entry.trim().to_string(); - addr = addr.replace(',', ""); - if addr.contains('@') { - return Ok(OrdEmailAddress( - EmailAddress::parse(&addr, None) - .ok_or::>(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>> @@ -190,6 +205,23 @@ mod tests { assert!(result.is_err()); } + #[test] + fn apostrophe_destination_detection() { + let result = parse_alias_to_map(vec![AliasFile { + content: read_lines("testdata/apostrophe_destination.aliases").unwrap(), + default_domain: FQDN::from_str("example.com").unwrap(), + }]); + assert!(result.is_err()); + } + #[test] + fn apostrophe_redirect_detection() { + let result = parse_alias_to_map(vec![AliasFile { + content: read_lines("testdata/apostrophe_redirect.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 { diff --git a/packages/alias-to-sieve/testdata/apostrophe_destination.aliases b/packages/alias-to-sieve/testdata/apostrophe_destination.aliases new file mode 100644 index 0000000..5cc3e26 --- /dev/null +++ b/packages/alias-to-sieve/testdata/apostrophe_destination.aliases @@ -0,0 +1,2 @@ +# Apostrophes are not allowed +'orga me@example.com diff --git a/packages/alias-to-sieve/testdata/apostrophe_redirect.aliases b/packages/alias-to-sieve/testdata/apostrophe_redirect.aliases new file mode 100644 index 0000000..d6b499f --- /dev/null +++ b/packages/alias-to-sieve/testdata/apostrophe_redirect.aliases @@ -0,0 +1,2 @@ +# Apostrophes are not allowed +orga me@e'xample.com