forked from Fachschaft/nixConfig
Compare commits
2 commits
38d6a89405
...
7b68481e72
Author | SHA1 | Date | |
---|---|---|---|
7b68481e72 | |||
aa210d868b |
5 changed files with 129 additions and 29 deletions
|
@ -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"]}
|
||||
|
|
60
packages/alias-to-sieve/README.md
Normal file
60
packages/alias-to-sieve/README.md
Normal file
|
@ -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.
|
|
@ -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<AliasEmailAddress, Box<dyn Error>> {
|
||||
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::<Box<dyn Error>>(
|
||||
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<Ordering> {
|
||||
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<OrdEmailAddress, Vec<OrdEmailAddress>>;
|
||||
pub type AliasMap = BTreeMap<AliasEmailAddress, Vec<AliasEmailAddress>>;
|
||||
|
||||
/// 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.
|
||||
pub fn parse_alias_to_map(alias_files: Vec<AliasFile>) -> Result<AliasMap, Box<dyn Error>> {
|
||||
// File must exist in the current path
|
||||
let mut redirect_map: AliasMap = AliasMap::new();
|
||||
let mut destinations: Vec<OrdEmailAddress> = Vec::new();
|
||||
let mut destinations: Vec<AliasEmailAddress> = 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<AliasFile>) -> Result<AliasMap, Box<d
|
|||
continue;
|
||||
}
|
||||
|
||||
let redirects: Vec<OrdEmailAddress> = line
|
||||
let redirects: Vec<AliasEmailAddress> = 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::<Result<Vec<_>, _>>()?;
|
||||
|
||||
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<AliasFile>) -> Result<AliasMap, Box<d
|
|||
}
|
||||
}
|
||||
if iterations == max_iterations {
|
||||
return Err(String::from("Possibly infinite recursion detected in parse_alias_map. Did not terminate after {max_iterations} rounds.").into());
|
||||
return Err(format!("Possibly infinite recursion detected in parse_alias_map. Did not terminate after {max_iterations} rounds.").into());
|
||||
}
|
||||
Ok(redirect_map)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
) -> Result<OrdEmailAddress, Box<dyn Error>> {
|
||||
let mut addr = alias_entry.trim().to_string();
|
||||
addr = addr.replace(',', "");
|
||||
if addr.contains('@') {
|
||||
return Ok(OrdEmailAddress(
|
||||
EmailAddress::parse(&addr, None)
|
||||
.ok_or::<Box<dyn Error>>(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<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
|
||||
|
@ -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 {
|
||||
|
|
2
packages/alias-to-sieve/testdata/apostrophe_destination.aliases
vendored
Normal file
2
packages/alias-to-sieve/testdata/apostrophe_destination.aliases
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
# Apostrophes are not allowed
|
||||
'orga me@example.com
|
2
packages/alias-to-sieve/testdata/apostrophe_redirect.aliases
vendored
Normal file
2
packages/alias-to-sieve/testdata/apostrophe_redirect.aliases
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
# Apostrophes are not allowed
|
||||
orga me@e'xample.com
|
Loading…
Add table
Add a link
Reference in a new issue