daaki-smtp 0.2.0

An async SMTP client library
Documentation
#![allow(clippy::unwrap_used, clippy::expect_used)]

use super::*;

// ── Domain ──────────────────────────────────────────────────────────

#[test]
fn domain_valid() {
    assert!(Domain::new("example.com").is_ok());
    assert!(Domain::new("mail.example.co.uk").is_ok());
    assert!(Domain::new("a").is_ok());
    assert!(Domain::new("a-b.example").is_ok());
}

#[test]
fn domain_rejects_empty() {
    assert!(Domain::new("").is_err());
}

#[test]
fn domain_rejects_overlong_label() {
    let long = "a".repeat(64);
    assert!(Domain::new(format!("{long}.com")).is_err());
}

#[test]
fn domain_rejects_empty_label() {
    assert!(Domain::new("mail..example.com").is_err());
}

#[test]
fn domain_rejects_leading_hyphen() {
    assert!(Domain::new("-example.com").is_err());
}

#[test]
fn domain_rejects_address_literal() {
    assert!(Domain::new("[127.0.0.1]").is_err());
}

// ── AddressLiteral ──────────────────────────────────────────────────

#[test]
fn address_literal_ipv4() {
    assert!(AddressLiteral::new("[127.0.0.1]").is_ok());
    assert!(AddressLiteral::new("[192.168.1.1]").is_ok());
}

#[test]
fn address_literal_ipv6() {
    assert!(AddressLiteral::new("[IPv6:::1]").is_ok());
    assert!(AddressLiteral::new("[IPv6:2001:db8::1]").is_ok());
}

#[test]
fn address_literal_rejects_no_brackets() {
    assert!(AddressLiteral::new("127.0.0.1").is_err());
}

#[test]
fn address_literal_rejects_empty_body() {
    assert!(AddressLiteral::new("[]").is_err());
}

#[test]
fn address_literal_rejects_invalid_ipv6() {
    assert!(AddressLiteral::new("[IPv6:not-valid]").is_err());
}

#[test]
fn address_literal_generalized_tag_allows_leading_hyphen() {
    assert!(AddressLiteral::new("[-tag:opaque]").is_ok());
}

// ── DomainOrLiteral ─────────────────────────────────────────────────

#[test]
fn domain_or_literal_domain() {
    let d = DomainOrLiteral::new("example.com").unwrap();
    assert!(matches!(d, DomainOrLiteral::Domain(_)));
}

#[test]
fn domain_or_literal_ipv4() {
    let d = DomainOrLiteral::new("[127.0.0.1]").unwrap();
    assert!(matches!(d, DomainOrLiteral::Literal(_)));
}

// ── Mailbox ─────────────────────────────────────────────────────────

#[test]
fn mailbox_valid() {
    assert!(Mailbox::new("user@example.com").is_ok());
    assert!(Mailbox::new("a.b@example.com").is_ok());
}

#[test]
fn mailbox_accepts_quoted_local_part_with_at_sign() {
    let mailbox = Mailbox::new("\"user@inner\"@example.com").unwrap();
    assert_eq!(mailbox.as_str(), "\"user@inner\"@example.com");
    assert!(!mailbox.requires_smtputf8());
}

#[test]
fn mailbox_accepts_quoted_local_part_with_parentheses() {
    let mailbox = Mailbox::new("\"user(comment)\"@example.com").unwrap();
    assert_eq!(mailbox.as_str(), "\"user(comment)\"@example.com");
}

#[test]
fn mailbox_rejects_tab_in_quoted_local_part() {
    assert!(
        Mailbox::new("\"user\tinner\"@example.com").is_err(),
        "RFC 5321 Section 4.1.2 qtextSMTP excludes HTAB in quoted local-parts"
    );
    assert!(
        Mailbox::new("\"user\\\tinner\"@example.com").is_err(),
        "RFC 5321 Section 4.1.2 quoted-pairSMTP permits only ASCII SP/VCHAR after '\\\\'"
    );
}

#[test]
fn mailbox_rejects_display_name() {
    assert!(Mailbox::new("User <user@example.com>").is_err());
}

#[test]
fn mailbox_rejects_overlong_local() {
    let long_local = "a".repeat(65);
    assert!(Mailbox::new(format!("{long_local}@example.com")).is_err());
}

#[test]
fn mailbox_rejects_whitespace() {
    assert!(Mailbox::new(" user@example.com").is_err());
    assert!(Mailbox::new("user@example.com ").is_err());
}

#[test]
fn mailbox_rejects_rfc5322_only_domain_literal() {
    assert!(
        Mailbox::new("user@[10,0,0,1]").is_err(),
        "SMTP mailbox syntax must reject RFC 5322-only domain-literals \
         that are not valid RFC 5321 address-literals"
    );
}

#[test]
fn mailbox_requires_smtputf8() {
    // ASCII mailbox does not require SMTPUTF8
    let m = Mailbox::new("user@example.com").unwrap();
    assert!(!m.requires_smtputf8());
}

// ── ReversePath ─────────────────────────────────────────────────────

#[test]
fn reverse_path_null() {
    let rp = ReversePath::new("").unwrap();
    assert!(matches!(rp, ReversePath::Null));
    assert_eq!(rp.as_str(), "");
}

#[test]
fn reverse_path_mailbox() {
    let rp = ReversePath::new("user@example.com").unwrap();
    assert!(matches!(rp, ReversePath::Mailbox(_)));
    assert_eq!(rp.as_str(), "user@example.com");
}

#[test]
fn reverse_path_accepts_quoted_local_part_with_at_sign() {
    let rp = ReversePath::new("\"user@inner\"@example.com").unwrap();
    assert_eq!(rp.as_str(), "\"user@inner\"@example.com");
}

#[test]
fn reverse_path_accepts_and_ignores_source_route() {
    // RFC 5321 Section 4.1.2: source routes in `Path` syntax are
    // obsolete, but they MUST be accepted and SHOULD be ignored.
    let rp = ReversePath::new("@old.example,@relay.example:user@example.com").unwrap();
    assert_eq!(rp.as_str(), "user@example.com");
}

#[test]
fn reverse_path_accepts_utf8_source_route_domains() {
    // RFC 5321 Section 4.1.2 defines source routes using `Domain`, and
    // RFC 6531 Section 3.3 extends `sub-domain` with U-labels. SMTPUTF8
    // paths therefore must accept UTF-8 route domains and still ignore
    // the obsolete route itself.
    let rp = ReversePath::new("@例え.jp,@domínio.example:user@example.com").unwrap();
    assert_eq!(rp.as_str(), "user@example.com");
}

#[test]
fn reverse_path_rejects_overlong_source_route_path() {
    // RFC 5321 Section 4.5.3.1.3 limits the full `Path`, including the
    // obsolete route accepted by RFC 5321 Section 4.1.2.
    let route_domain = format!("{}.{}.{}", "u".repeat(63), "v".repeat(63), "w".repeat(62));
    let address = format!("@{route_domain},@{route_domain}:user@example.com");

    assert!(
        address.len() + 2 > 256,
        "test precondition: source-routed reverse-path must exceed the RFC 5321 256-octet limit"
    );

    let err = ReversePath::new(&address).expect_err(
        "source-routed reverse-path exceeding 256 octets must be rejected \
         per RFC 5321 Section 4.5.3.1.3",
    );
    let msg = err.to_string();
    assert!(
        msg.contains("256") || msg.contains("path"),
        "error should mention the path-length limit: {msg}"
    );
}

// ── ForwardPath ─────────────────────────────────────────────────────

#[test]
fn forward_path_postmaster() {
    let fp = ForwardPath::new("Postmaster").unwrap();
    assert!(matches!(fp, ForwardPath::Postmaster));

    let fp2 = ForwardPath::new("postmaster").unwrap();
    assert!(matches!(fp2, ForwardPath::Postmaster));
}

#[test]
fn forward_path_mailbox() {
    let fp = ForwardPath::new("user@example.com").unwrap();
    assert!(matches!(fp, ForwardPath::Mailbox(_)));
}

#[test]
fn forward_path_accepts_quoted_local_part_with_at_sign() {
    let fp = ForwardPath::new("\"user@inner\"@example.com").unwrap();
    assert_eq!(fp.as_str(), "\"user@inner\"@example.com");
}

#[test]
fn forward_path_accepts_and_ignores_source_route() {
    // RFC 5321 Section 4.1.2: source routes in `Path` syntax are
    // obsolete, but they MUST be accepted and SHOULD be ignored.
    let fp = ForwardPath::new("@old.example,@relay.example:user@example.com").unwrap();
    assert_eq!(fp.as_str(), "user@example.com");
}

#[test]
fn forward_path_accepts_utf8_source_route_domains() {
    // RFC 6531 Section 3.3 extends SMTP domains with U-labels, so the
    // obsolete `A-d-l` route in RFC 5321 Section 4.1.2 must not reject
    // UTF-8 route domains when parsing an SMTPUTF8 path.
    let fp = ForwardPath::new("@例え.jp,@domínio.example:user@example.com").unwrap();
    assert_eq!(fp.as_str(), "user@example.com");
}

// ── EnvidValue ──────────────────────────────────────────────────────

#[test]
fn envid_valid() {
    assert!(EnvidValue::new("abc123").is_ok());
    assert!(EnvidValue::new("msg-001@server").is_ok());
}

#[test]
fn envid_rejects_empty() {
    assert!(EnvidValue::new("").is_err());
}

#[test]
fn envid_rejects_overlong() {
    assert!(EnvidValue::new("a".repeat(101)).is_err());
}

#[test]
fn envid_rejects_control_chars() {
    assert!(EnvidValue::new("abc\x00def").is_err());
}

// ── XtextSafe ───────────────────────────────────────────────────────

#[test]
fn xtext_safe_valid() {
    assert!(XtextSafe::new("hello").is_ok());
    assert!(XtextSafe::new("user@example.com").is_ok());
}

#[test]
fn xtext_safe_rejects_empty() {
    assert!(XtextSafe::new("").is_err());
}

#[test]
fn xtext_safe_rejects_control_chars() {
    assert!(XtextSafe::new("abc\x00").is_err());
    assert!(XtextSafe::new("abc\n").is_err());
}

#[test]
fn xtext_safe_rejects_cr_lf() {
    // CR (0x0D) and LF (0x0A) are control characters below 0x20 and must
    // be rejected by the general control-character check in XtextSafe::new
    // (RFC 5321 Section 4.1.2).
    assert!(
        XtextSafe::new("hello\rworld").is_err(),
        "bare CR must be rejected"
    );
    assert!(
        XtextSafe::new("hello\nworld").is_err(),
        "bare LF must be rejected"
    );
    assert!(
        XtextSafe::new("hello\r\nworld").is_err(),
        "CRLF must be rejected"
    );
}

#[test]
fn xtext_safe_rejects_non_ascii() {
    assert!(XtextSafe::new("café").is_err());
}

// ── validate_smtputf8_domain_syntax ────────────────────────────────

#[test]
fn smtputf8_domain_rejects_overlong_non_ascii_label() {
    // RFC 5321 Section 4.5.3.1.2 / RFC 1035 Section 2.3.4: labels ≤ 63 octets.
    // 32 × 'é' (U+00E9, 2 bytes in UTF-8) = 64 bytes — must be rejected.
    let label = "é".repeat(32);
    assert_eq!(label.len(), 64, "test precondition: label must be 64 bytes");
    let domain = format!("{label}.com");
    let err = validate_smtputf8_domain_syntax(&domain)
        .expect_err("non-ASCII label of 64 bytes must be rejected");
    let msg = err.to_string();
    assert!(
        msg.contains("63") || msg.contains("label"),
        "error should mention the label-length limit: {msg}"
    );
}

#[test]
fn smtputf8_domain_accepts_max_non_ascii_label() {
    // 31 × 'é' (62 bytes) + 1 ASCII char = 63 bytes — exactly at the limit.
    let label = format!("{}a", "é".repeat(31));
    assert_eq!(label.len(), 63, "test precondition: label must be 63 bytes");
    let domain = format!("{label}.com");
    assert!(
        validate_smtputf8_domain_syntax(&domain).is_ok(),
        "non-ASCII label of exactly 63 bytes must be accepted"
    );
}