feat: Support addresses using domain literals (#42)

If the incoming email comes from address that uses
domain literals `[<ipv4>]` or `[IPv6:<ipv6>]`,
skip DKIM verification and instead check IP
alignment with originating IP from XFORWARD
command.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
Jagoda Estera Ślązak
2026-02-16 18:25:21 +01:00
committed by GitHub
parent a096d0550f
commit 4837754245
5 changed files with 109 additions and 29 deletions
+58 -12
View File
@@ -1,4 +1,5 @@
use mailparse::MailAddr;
use std::str::FromStr;
/// Extracts the first email address found in SMTP command or email header.
///
@@ -26,14 +27,51 @@ pub fn extract_address(input: &str) -> Option<String> {
})
}
pub fn get_domain_from_address(address: &str) -> Option<String> {
let parts: Vec<&str> = address.split('@').collect();
if parts.len() == 2
&& let Some(domain) = parts.get(1)
{
Some(domain.to_string())
} else {
None
/// Domain part of an email address, either a domain-literal (IP address in square brackets with
/// optional protocol prefix) or a regular domain name.
#[derive(Debug, PartialEq, Eq)]
pub enum AddressDomain {
/// Domain literal, e.g.
/// - `192.0.2.0` in `test@[192.0.2.0]`,
/// - `2001:db8::1` in `test@[IPv6:2001:db8::1]`.
Literal(String),
/// Regular domain name, e.g. `example.org` in `test@example.org`.
Name(String),
}
impl FromStr for AddressDomain {
type Err = crate::error::Error;
/// Extracts the domain part from an email address and returns it as an [`AddressDomain`].
///
/// Returns an [`Error`] if `address` is not a valid email address.
///
/// [`Error`]: crate::error::Error
fn from_str(address: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = address.split('@').collect();
if parts.len() == 2
&& let Some(domain) = parts.get(1)
{
// domain literals
if domain.starts_with('[') && domain.ends_with(']') {
let mut address_trimmed = domain
.get(1..domain.len() - 1)
.unwrap_or(domain)
.to_lowercase();
address_trimmed = address_trimmed
.strip_prefix("ipv6:")
.unwrap_or(&address_trimmed)
.to_string();
return Ok(AddressDomain::Literal(address_trimmed.to_string()));
}
Ok(AddressDomain::Name(domain.to_string()))
} else {
Err(crate::error::Error::InvalidEmailAddress(
address.to_string(),
))
}
}
}
@@ -51,18 +89,26 @@ mod tests {
#[case("mail from:<t4@example.org>", Some("t4@example.org".to_string()))]
#[case("Foo Bar <t5@example.org>", Some("t5@example.org".to_string()))]
#[case("t6@example.org", Some("t6@example.org".to_string()))]
#[case("t7@[192.0.2.0]", Some("t7@[192.0.2.0]".to_string()))]
#[case("<t7@[192.0.2.0]>", Some("t7@[192.0.2.0]".to_string()))]
// This is a bug in mailparse, it refuses to parse IPv6 without "<>" around.
// https://github.com/staktrace/mailparse/issues/137
#[case("t8@[IPv6:2001:db8::1]", None)]
#[case("<t8@[IPv6:2001:db8::1]>", Some("t8@[ipv6:2001:db8::1]".to_string()))]
fn test_extract_address(#[case] input: &str, #[case] expected: Option<String>) {
let result = extract_address(input);
assert_eq!(result, expected)
}
#[rstest]
#[case("t1@example.org", Some("example.org".to_string()))]
#[case("SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net", Some("example.net".to_string()))]
#[case("t1@example.org", Some(AddressDomain::Name("example.org".to_string())))]
#[case("SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net", Some(AddressDomain::Name("example.net".to_string())))]
#[case("t7@[192.0.2.0]", Some(AddressDomain::Literal("192.0.2.0".to_string())))]
#[case("t8@[IPv6:2001:db8::1]", Some(AddressDomain::Literal("2001:db8::1".to_string())))]
#[case("invalid", None)]
#[case("invalid@address@com", None)]
fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option<String>) {
let result = get_domain_from_address(input);
fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option<AddressDomain>) {
let result = AddressDomain::from_str(input).ok();
assert_eq!(result, expected);
}
}