Skip to main content

mailrs_outbound_queue/
dkim_sign.rs

1//! Outbound DKIM signing + ARC sealing, on the mailrs-* crates.
2
3use std::collections::HashMap;
4use std::sync::{Arc, OnceLock};
5
6use mailrs_arc::{
7    ArcChain, ArcSealCv, ArcSigningKey, Canon as ArcCanon, ChainOutcome, SealOpts,
8    seal as arc_seal, verify_chain_with_crypto,
9};
10use mailrs_dkim::{
11    Canon as DkimCanon, DkimSigningKey, HickoryDkimResolver, RsaSigningKey, SignOpts,
12    sign as dkim_sign, verify_all,
13};
14
15/// One DKIM key bound to a single signing domain — used as a value
16/// in [`DkimSignConfig::extra_keys`] for multi-domain signing.
17///
18/// The PKCS#8 PEM is parsed into an `RsaSigningKey` lazily on first
19/// use and cached for the lifetime of this entry, same as the
20/// default key on `DkimSignConfig`.
21#[derive(Debug, Clone, Default)]
22pub struct DkimDomainKey {
23    /// DKIM selector — the label under `<selector>._domainkey.<this domain>`.
24    pub selector: String,
25    /// Private RSA key in PKCS#8 PEM form.
26    pub private_key_pem: String,
27    /// Lazy-parsed `RsaSigningKey`, shared across clones.
28    #[doc(hidden)]
29    pub parsed_key: Arc<OnceLock<Result<RsaSigningKey, String>>>,
30}
31
32impl DkimDomainKey {
33    fn rsa_key(&self) -> Result<&RsaSigningKey, String> {
34        let cached = self
35            .parsed_key
36            .get_or_init(|| load_rsa_key(&self.private_key_pem));
37        match cached {
38            Ok(k) => Ok(k),
39            Err(e) => Err(e.clone()),
40        }
41    }
42}
43
44/// DKIM signing configuration.
45///
46/// The PKCS#8 PEM is parsed into an `RsaSigningKey` lazily on first use
47/// and cached for the lifetime of this config — important on the hot
48/// outbound path where every delivered message triggers a sign call.
49/// Existing struct-literal callers need to add `..Default::default()`
50/// (the parsed-key cache is non-public, populated on demand).
51///
52/// **v3 (mailrs-dkim 3.0)**: `RsaSigningKey` wraps aws-lc-rs's
53/// `RsaKeyPair`, so per-sign cost dropped from ~1.5 ms (pure-Rust
54/// `rsa` crate) to ~0.5 ms.
55///
56/// **v4 (2026-06-03)**: added [`extra_keys`](Self::extra_keys) to
57/// support per-domain DKIM signing. [`sign`](Self::sign) parses the
58/// message's `From:` header and looks up the matching key by domain
59/// (exact match → ancestor-domain suffix walk → default). The
60/// pre-v4 single-domain config (just `selector` / `domain` /
61/// `private_key_pem`) keeps working unchanged — `extra_keys` empty
62/// = old behaviour.
63#[derive(Debug, Clone, Default)]
64pub struct DkimSignConfig {
65    /// DKIM selector — the label under `<selector>._domainkey.<domain>`.
66    /// Used as the default when the message's From: domain doesn't
67    /// match any `extra_keys` entry.
68    pub selector: String,
69    /// Signing domain (matches the `d=` tag in the DKIM-Signature header).
70    /// Default `d=` when no `extra_keys` entry matches.
71    pub domain: String,
72    /// Private RSA key in PKCS#8 PEM form.
73    pub private_key_pem: String,
74    /// Lazy-parsed `RsaSigningKey`, shared across clones so worker
75    /// concurrency doesn't re-parse per delivery thread.
76    ///
77    /// **Implementation detail** — leave at `Default::default()`. Pub
78    /// only so out-of-crate struct-literal callers can spread
79    /// `..Default::default()`. Reading or mutating this field
80    /// directly is not supported and the type may change.
81    #[doc(hidden)]
82    pub parsed_key: Arc<OnceLock<Result<RsaSigningKey, String>>>,
83    /// Extra DKIM keys keyed by signing domain, used when the
84    /// message's `From:` header domain matches an entry (exact match
85    /// first, then ancestor-suffix walk — e.g. `mail.example.com`
86    /// falls back to `example.com`). When no entry matches, signing
87    /// uses the default `selector`/`domain`/`private_key_pem` above.
88    ///
89    /// Keep `extra_keys.is_empty()` for the single-domain config.
90    pub extra_keys: HashMap<String, DkimDomainKey>,
91}
92
93impl DkimSignConfig {
94    /// Return a borrowed handle to the parsed RSA key (default key
95    /// only), parsing the PEM once on first call and caching the
96    /// result (success OR error) so every later call is a
97    /// pointer-load.
98    fn rsa_key(&self) -> Result<&RsaSigningKey, String> {
99        let cached = self
100            .parsed_key
101            .get_or_init(|| load_rsa_key(&self.private_key_pem));
102        match cached {
103            Ok(k) => Ok(k),
104            Err(e) => Err(e.clone()),
105        }
106    }
107
108    /// Look up the (`d=` domain, selector, RSA key) triple for a given
109    /// `From:` header domain. Lookup order:
110    ///
111    /// 1. **Exact match** in [`extra_keys`](Self::extra_keys).
112    /// 2. **Ancestor-suffix walk**: strip the leading label one at a
113    ///    time and try `extra_keys` (so `mail.example.com` falls back
114    ///    to `example.com`, useful for system mail like
115    ///    `postmaster@mail.<your-org>` signed with the parent-domain
116    ///    key — DMARC alignment under `adkim=r` accepts subdomain →
117    ///    parent-domain alignment).
118    /// 3. **Default**: the top-level `selector` / `domain` /
119    ///    `private_key_pem` on this config.
120    ///
121    /// Returns `(d_value, selector, parsed_rsa_key)` with `d_value`
122    /// being the matched key (or the default `domain` field).
123    fn key_for_from_domain(
124        &self,
125        from_domain: &str,
126    ) -> Result<(String, String, RsaSigningKey), String> {
127        // 1. exact match
128        if let Some(dk) = self.extra_keys.get(from_domain) {
129            return Ok((
130                from_domain.to_string(),
131                dk.selector.clone(),
132                dk.rsa_key()?.clone(),
133            ));
134        }
135        // 2. suffix walk — strip the leading label and try again.
136        //    The walk stops once there's only one label left (we never
137        //    match against a bare TLD).
138        let mut rest = from_domain;
139        while let Some(idx) = rest.find('.') {
140            let parent = &rest[idx + 1..];
141            // require at least one '.' remaining (so we don't match
142            // against a bare TLD like "com")
143            if !parent.contains('.') {
144                break;
145            }
146            if let Some(dk) = self.extra_keys.get(parent) {
147                return Ok((
148                    parent.to_string(),
149                    dk.selector.clone(),
150                    dk.rsa_key()?.clone(),
151                ));
152            }
153            rest = parent;
154        }
155        // 3. default
156        Ok((
157            self.domain.clone(),
158            self.selector.clone(),
159            self.rsa_key()?.clone(),
160        ))
161    }
162
163    /// Sign a message, prepending the DKIM-Signature header.
164    ///
165    /// Parses the message's `From:` header to determine the signing
166    /// domain via [`key_for_from_domain`](Self::key_for_from_domain).
167    /// If no `From:` header is present (or it's malformed enough that
168    /// no domain can be extracted), falls back to the default
169    /// `domain`/`selector`/`key` on this config.
170    pub fn sign(&self, message: &[u8]) -> Result<Vec<u8>, String> {
171        let from_domain = extract_from_domain(message);
172        let (d_value, selector, rsa) = match from_domain.as_deref() {
173            Some(d) => self.key_for_from_domain(d)?,
174            None => (
175                self.domain.clone(),
176                self.selector.clone(),
177                self.rsa_key()?.clone(),
178            ),
179        };
180        let key = DkimSigningKey::Rsa(rsa);
181        let opts = SignOpts {
182            domain: d_value,
183            selector,
184            signed_headers: ["From", "To", "Subject", "Date", "Message-ID"]
185                .iter()
186                .map(|&s| s.to_string())
187                .collect(),
188            canon_header: DkimCanon::Relaxed,
189            canon_body: DkimCanon::Relaxed,
190            identity: None,
191            timestamp: None,
192            expiration: None,
193            body_length: None,
194        };
195        let header =
196            dkim_sign(message, &key, &opts).map_err(|e| format!("DKIM signing failed: {e}"))?;
197        let mut signed = Vec::with_capacity(header.len() + message.len());
198        signed.extend_from_slice(header.as_bytes());
199        signed.extend_from_slice(message);
200        Ok(signed)
201    }
202}
203
204/// Extract the domain part of the message's `From:` header, if any.
205///
206/// Tolerates the three common shapes:
207/// - `alice@example.com`
208/// - `Alice <alice@example.com>`
209/// - `"Display Name" <alice@example.com>`
210///
211/// Returns `None` if no `From:` header exists, the header value
212/// contains no `@`, or the domain segment is empty.
213pub fn extract_from_domain(message: &[u8]) -> Option<String> {
214    let m = mailrs_rfc5322::Message::new(message);
215    let from_value = m.header("From")?;
216    let s = std::str::from_utf8(from_value).ok()?;
217    let at = s.rfind('@')?;
218    let after = &s[at + 1..];
219    // Walk forward until a terminator: '>', whitespace, comma, semicolon.
220    let end = after
221        .find(|c: char| c == '>' || c == ',' || c == ';' || c.is_whitespace())
222        .unwrap_or(after.len());
223    let domain = after[..end].trim().to_ascii_lowercase();
224    if domain.is_empty() {
225        None
226    } else {
227        Some(domain)
228    }
229}
230
231/// Extract domain from email address.
232pub fn extract_domain(email: &str) -> Option<&str> {
233    email.rsplit_once('@').map(|(_, domain)| domain)
234}
235
236/// ARC-seal a forwarded message, preserving the authentication chain
237/// (RFC 8617). Uses `mailrs-dkim` + `mailrs-arc` for verify-then-seal.
238pub async fn arc_seal_message(
239    dkim_config: &DkimSignConfig,
240    dkim_resolver: &HickoryDkimResolver,
241    message: &[u8],
242) -> Result<Vec<u8>, String> {
243    // 1. Verify existing DKIM signatures — used as our hop's authres body.
244    let dkim_outputs = verify_all(dkim_resolver, message).await;
245
246    // 2. Extract + verify the prior ARC chain (if any). `cv=` on the
247    //    new seal mirrors the verdict.
248    let prior_chain =
249        ArcChain::extract(message).map_err(|e| format!("ARC chain extract failed: {e}"))?;
250    let cv = match prior_chain.as_ref() {
251        None => ArcSealCv::None,
252        Some(chain) => {
253            match verify_chain_with_crypto(chain, dkim_resolver, message)
254                .await
255                .map_err(|e| format!("ARC chain verify failed: {e}"))?
256            {
257                ChainOutcome::Pass => ArcSealCv::Pass,
258                _ => ArcSealCv::Fail,
259            }
260        }
261    };
262
263    // 3. Build the AAR body — coarse DKIM verdict per signature, in
264    //    the same shape an `Authentication-Results` header would carry.
265    let authres = build_authres_body(&dkim_config.domain, &dkim_outputs);
266
267    // 4. Load the signing key and seal. Reuse the lazy-parsed cache
268    //    on DkimSignConfig so we don't re-parse PEM per outbound msg.
269    let rsa = dkim_config.rsa_key()?;
270    let key = ArcSigningKey::Rsa(rsa);
271    let opts = SealOpts {
272        domain: dkim_config.domain.clone(),
273        selector: dkim_config.selector.clone(),
274        signed_headers: [
275            "From",
276            "To",
277            "Subject",
278            "Date",
279            "Message-ID",
280            "DKIM-Signature",
281        ]
282        .iter()
283        .map(|&s| s.to_string())
284        .collect(),
285        canon_header: ArcCanon::Relaxed,
286        canon_body: ArcCanon::Relaxed,
287        cv,
288        authres,
289        timestamp: None,
290    };
291    let sealed = arc_seal(message, &key, &opts, prior_chain.as_ref())
292        .map_err(|e| format!("ARC sealing failed: {e}"))?;
293
294    let prepend = sealed.concat();
295    let mut out = Vec::with_capacity(prepend.len() + message.len());
296    out.extend_from_slice(prepend.as_bytes());
297    out.extend_from_slice(message);
298    Ok(out)
299}
300
301fn load_rsa_key(pem: &str) -> Result<RsaSigningKey, String> {
302    RsaSigningKey::from_pkcs8_pem(pem).map_err(|e| format!("failed to parse DKIM key: {e}"))
303}
304
305fn build_authres_body(authserv_id: &str, outputs: &[mailrs_dkim::SignatureOutput]) -> String {
306    use std::fmt::Write as _;
307    let mut s = String::with_capacity(64);
308    s.push_str(authserv_id);
309    if outputs.is_empty() {
310        s.push_str("; dkim=none");
311        return s;
312    }
313    for o in outputs {
314        let verdict = if o.is_pass() { "pass" } else { "fail" };
315        let d = o.domain();
316        // write! avoids the per-iter intermediate String alloc the
317        // previous push_str(&format!(...)) pair was paying.
318        if d.is_empty() {
319            let _ = write!(s, "; dkim={verdict}");
320        } else {
321            let _ = write!(s, "; dkim={verdict} header.d={d}");
322        }
323    }
324    s
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    const TEST_RSA_KEY: &str = "-----BEGIN PRIVATE KEY-----\n\
332MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDNZMkvBc/kAdQl\n\
333GFY6ADYW+guQCJU4x6Zulb4/4fMDUHruL/DR722wV+qKmivIP5SS5X7H+U5X6xha\n\
3341r70zJpdpEzyVZtctBZzm1BkKq81BVdL3iJCbVmPPqs2pUOjGsInmM7gEfvhz7CB\n\
335q+RQ1fb9iGlBA/WmNqLKiVg1nEVDai6DHzEofI+Ta8ij5yGnYHVLJLsqmJotyvHN\n\
3362vi/7kIFigjW/4TOQLcaZGm8AYTEBH4opqvb8C460vLjUFBHeoSqm0vkHWzrwNQx\n\
337S29LczFc/WIpQkl1rx5iS8E5QI2u4eCHVElAjZp4IJsyPYBGVN72mi37IGfEjkHS\n\
338O2TIUEQhAgMBAAECggEABK/ZlWydB1dxV11cTluF4HVZQTKo8RBBQIHDQyLtUDSM\n\
339cZX/eVLs3lrLO9lzyVCGG+oHwBl0y7XOKvh+iAiJNzzSEq+YaX+kiYPQTFDbCasz\n\
340CESr5HcpVYb5EjioN/ca2ht3EQ7oAAmkvfjFr4CKb9Omjzi/aMkTYurKbALCY9zk\n\
341bx8J9VADe1aAAA54WFxIlJvb72Hrfw8iflFqVZNzykRp6tUvJJgSqLOpfM0ut5zb\n\
3420ClgCjSZ7HpehjWVm3KBAOcC7p2TL3erpWoG9BuatgYLLRhW/AzLzXZ3/hSu9kEn\n\
343ihws+VXkHxeaIafrck0HQyWnHb9QEcSgfVIhAYztlwKBgQD3O684316go2e6Qf4I\n\
3447rF4JwmQiI+NMAQq55AwquZkfuw0N2F9AgyuzGskYvI9Ok+l/wP1e8Mb6JRuP6Nj\n\
345dPYTQwzfmyZgdOovxGkZOGE60EQuX/1IS/NbLKQySAphgBVR2FlHnu+VMvha61tm\n\
346/5K1ROAB3Ng3FbR7rHJXFjWU+wKBgQDUrUtS3Yj0yHnxA/AL04lsxNrLlinEVDM1\n\
3476wPjC2VEXhj2j4JNrVqXG4GVYYEGhkUTjwcTOiZfmHaqMzEFo1aTOoiLrMMLQjmm\n\
348jPNkLHsDXcbG5FA0BbzQmlj+ixKPToh2gHfeMfH96YmdROfmvY/TN9yI1FgkLErL\n\
349YKatCKWokwKBgC6z25nGuD1oIMQSi0ZssKGd3jSrV1K4a1EfhSFsZzE8uKn0fDn9\n\
350FSBABU1OU6w1Q657yeephWXUPZXF97tl8MYauGfVCx7Vdxem5qOY/uT5SqfoAhSS\n\
351JFpoyGunKC7a3ywizlq1L1Tj1/50z0NZrAEKDbbMXRuqwflKzh6dV2nZAoGBAImh\n\
352N6yBdr7J+bfRz4cntrgv0FONcqv9vUI4O0SzvC35Ivh0OGPiOkytXTd5aND7FTqq\n\
353BW8Y43pbpPdRt3ipkj4m0/RnsbTYf4xbjKqX6mdsSVWurIRt7hmkuNDI2RLqRH9D\n\
354dc7RzYN+nTKsQ9Jbe/a5ILtfh0apbyGcA2DYxrOHAoGAYYm/jwilVVaH1xSlP52w\n\
355BcpT8g8Wqgo4wFOTcyGJScBeFnQO1dhap+KNxCOyM/b2a8p2kQxHPmhIt+iyUpsM\n\
356Wob7+tvQ4QgOJAUWByTxMHczAY8Vrl45gxYS29ahbuvjtjPVLgHcaFnZPfun8i6u\n\
357/qw9cba4IgRYuEuLJ9bzbAY=\n\
358-----END PRIVATE KEY-----";
359
360    fn test_config() -> DkimSignConfig {
361        DkimSignConfig {
362            selector: "test".into(),
363            domain: "example.com".into(),
364            private_key_pem: TEST_RSA_KEY.into(),
365            ..Default::default()
366        }
367    }
368
369    fn simple_message() -> Vec<u8> {
370        b"From: sender@example.com\r\n\
371          To: recipient@example.com\r\n\
372          Subject: Test\r\n\
373          Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
374          Message-ID: <test@example.com>\r\n\
375          \r\n\
376          Hello, world!\r\n"
377            .to_vec()
378    }
379
380    #[test]
381    fn extract_domain_valid() {
382        assert_eq!(extract_domain("user@example.com"), Some("example.com"));
383    }
384
385    #[test]
386    fn extract_domain_no_at() {
387        assert_eq!(extract_domain("nope"), None);
388    }
389
390    #[test]
391    fn extract_domain_empty_string() {
392        assert_eq!(extract_domain(""), None);
393    }
394
395    #[test]
396    fn extract_domain_at_only() {
397        assert_eq!(extract_domain("@"), Some(""));
398    }
399
400    #[test]
401    fn extract_domain_multiple_at_signs_uses_last() {
402        assert_eq!(extract_domain("user@host@example.com"), Some("example.com"));
403    }
404
405    #[test]
406    fn extract_domain_subdomain() {
407        assert_eq!(
408            extract_domain("postmaster@mail.sub.example.com"),
409            Some("mail.sub.example.com")
410        );
411    }
412
413    #[test]
414    fn dkim_sign_invalid_pem_returns_error() {
415        let cfg = DkimSignConfig {
416            selector: "selector".into(),
417            domain: "example.com".into(),
418            private_key_pem: "not-a-valid-pem".into(),
419            ..Default::default()
420        };
421        let result = cfg.sign(b"From: test@example.com\r\n\r\nbody");
422        assert!(result.is_err());
423        let err = result.unwrap_err();
424        assert!(
425            err.contains("failed to parse DKIM key"),
426            "unexpected error: {err}"
427        );
428    }
429
430    #[test]
431    fn dkim_sign_empty_pem_returns_error() {
432        let cfg = DkimSignConfig {
433            selector: "sel".into(),
434            domain: "example.com".into(),
435            private_key_pem: String::new(),
436            ..Default::default()
437        };
438        let result = cfg.sign(b"From: test@example.com\r\n\r\n");
439        assert!(result.is_err());
440    }
441
442    #[test]
443    fn dkim_config_fields_stored() {
444        let cfg = DkimSignConfig {
445            selector: "myselector".into(),
446            domain: "mydomain.com".into(),
447            private_key_pem: "pem-data".into(),
448            ..Default::default()
449        };
450        assert_eq!(cfg.selector, "myselector");
451        assert_eq!(cfg.domain, "mydomain.com");
452        assert_eq!(cfg.private_key_pem, "pem-data");
453    }
454
455    #[test]
456    fn dkim_sign_prepends_dkim_signature_header() {
457        let cfg = test_config();
458        let msg = simple_message();
459        let signed = cfg.sign(&msg).unwrap();
460        let signed_str = String::from_utf8_lossy(&signed);
461        assert!(
462            signed_str.starts_with("DKIM-Signature:"),
463            "signed message must start with DKIM-Signature header"
464        );
465    }
466
467    #[test]
468    fn dkim_signature_contains_required_tags() {
469        let cfg = test_config();
470        let msg = simple_message();
471        let signed = cfg.sign(&msg).unwrap();
472        let signed_str = String::from_utf8_lossy(&signed);
473
474        let dkim_header = signed_str
475            .split_once("From: sender@example.com")
476            .expect("original message must follow signature")
477            .0;
478
479        assert!(dkim_header.contains("v=1"), "missing v= tag");
480        assert!(dkim_header.contains("a=rsa-sha256"), "missing a= tag");
481        assert!(dkim_header.contains("d=example.com"), "missing d= tag");
482        assert!(dkim_header.contains("s=test"), "missing s= tag");
483        assert!(dkim_header.contains("b="), "missing b= tag");
484        assert!(dkim_header.contains("bh="), "missing bh= tag");
485        assert!(dkim_header.contains("h="), "missing h= tag");
486    }
487
488    #[test]
489    fn dkim_signature_header_list_contains_signed_fields() {
490        let cfg = test_config();
491        let msg = simple_message();
492        let signed = cfg.sign(&msg).unwrap();
493        let signed_str = String::from_utf8_lossy(&signed);
494
495        let h_start = signed_str.find("h=").expect("h= tag missing");
496        let after_h = &signed_str[h_start..];
497        let h_value = after_h.split_once(';').map(|(v, _)| v).unwrap_or(after_h);
498
499        let h_lower = h_value.to_lowercase();
500        for expected in ["from", "to", "subject", "date", "message-id"] {
501            assert!(
502                h_lower.contains(expected),
503                "h= tag missing expected header: {expected}"
504            );
505        }
506    }
507
508    #[test]
509    fn dkim_sign_preserves_original_message() {
510        let cfg = test_config();
511        let msg = simple_message();
512        let signed = cfg.sign(&msg).unwrap();
513        assert!(
514            signed.ends_with(&msg),
515            "signed output must end with the original message bytes"
516        );
517    }
518
519    #[test]
520    fn dkim_sign_output_is_larger_than_input() {
521        let cfg = test_config();
522        let msg = simple_message();
523        let signed = cfg.sign(&msg).unwrap();
524        assert!(
525            signed.len() > msg.len(),
526            "signed message must be larger due to prepended header"
527        );
528    }
529
530    #[test]
531    fn dkim_sign_same_input_produces_same_body_hash() {
532        let cfg = test_config();
533        let msg = simple_message();
534        let signed1 = String::from_utf8_lossy(&cfg.sign(&msg).unwrap()).to_string();
535        let signed2 = String::from_utf8_lossy(&cfg.sign(&msg).unwrap()).to_string();
536
537        let extract_bh = |s: &str| -> String {
538            let start = s.find("bh=").unwrap() + 3;
539            let end = s[start..].find(';').map(|i| start + i).unwrap_or(s.len());
540            s[start..end].trim().to_string()
541        };
542
543        assert_eq!(
544            extract_bh(&signed1),
545            extract_bh(&signed2),
546            "body hash must be deterministic for identical input"
547        );
548    }
549
550    #[test]
551    fn dkim_sign_empty_body() {
552        let cfg = test_config();
553        let msg = b"From: sender@example.com\r\n\
554                     To: recipient@example.com\r\n\
555                     Subject: Empty body\r\n\
556                     Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
557                     Message-ID: <empty@example.com>\r\n\
558                     \r\n";
559        let result = cfg.sign(msg);
560        assert!(result.is_ok(), "signing an empty body must succeed");
561        let signed = result.unwrap();
562        let signed_str = String::from_utf8_lossy(&signed);
563        assert!(signed_str.starts_with("DKIM-Signature:"));
564    }
565
566    #[test]
567    fn dkim_sign_large_message() {
568        let cfg = test_config();
569        let headers = b"From: sender@example.com\r\n\
570                         To: recipient@example.com\r\n\
571                         Subject: Large message test\r\n\
572                         Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
573                         Message-ID: <large@example.com>\r\n\
574                         \r\n";
575        let body_line = b"The quick brown fox jumps over the lazy dog. \r\n";
576        let repeat_count = 1_000_000 / body_line.len();
577        let mut msg = headers.to_vec();
578        for _ in 0..repeat_count {
579            msg.extend_from_slice(body_line);
580        }
581
582        let result = cfg.sign(&msg);
583        assert!(result.is_ok(), "signing a ~1MB message must succeed");
584    }
585
586    #[test]
587    fn dkim_sign_custom_domain_and_selector() {
588        let cfg = DkimSignConfig {
589            selector: "mail2025".into(),
590            domain: "custom-mail.example.org".into(),
591            private_key_pem: TEST_RSA_KEY.into(),
592            ..Default::default()
593        };
594        let msg = simple_message();
595        let signed = cfg.sign(&msg).unwrap();
596        let signed_str = String::from_utf8_lossy(&signed);
597        assert!(signed_str.contains("d=custom-mail.example.org"));
598        assert!(signed_str.contains("s=mail2025"));
599    }
600
601    #[test]
602    fn build_authres_empty_outputs() {
603        let s = build_authres_body("mx.example.com", &[]);
604        assert_eq!(s, "mx.example.com; dkim=none");
605    }
606
607    #[test]
608    fn build_authres_starts_with_authserv_id() {
609        let s = build_authres_body("mx.example.com", &[]);
610        assert!(s.starts_with("mx.example.com"));
611    }
612
613    // ── multi-domain signing (v4 2026-06-03) ─────────────────
614
615    #[test]
616    fn extract_from_domain_bare_addr() {
617        let m = b"From: alice@example.com\r\n\r\nbody";
618        assert_eq!(extract_from_domain(m).as_deref(), Some("example.com"));
619    }
620
621    #[test]
622    fn extract_from_domain_angle_addr() {
623        let m = b"From: Alice <alice@example.com>\r\n\r\nbody";
624        assert_eq!(extract_from_domain(m).as_deref(), Some("example.com"));
625    }
626
627    #[test]
628    fn extract_from_domain_quoted_display_name() {
629        let m = b"From: \"Alice Liddell\" <alice@mail.example.com>\r\n\r\nbody";
630        assert_eq!(extract_from_domain(m).as_deref(), Some("mail.example.com"));
631    }
632
633    #[test]
634    fn extract_from_domain_lowercases() {
635        let m = b"From: <alice@MAIL.EXAMPLE.com>\r\n\r\nbody";
636        assert_eq!(extract_from_domain(m).as_deref(), Some("mail.example.com"));
637    }
638
639    #[test]
640    fn extract_from_domain_no_from_header() {
641        let m = b"To: bob@example.com\r\n\r\nbody";
642        assert!(extract_from_domain(m).is_none());
643    }
644
645    #[test]
646    fn extract_from_domain_no_at_sign() {
647        let m = b"From: garbage-no-at\r\n\r\nbody";
648        assert!(extract_from_domain(m).is_none());
649    }
650
651    #[test]
652    fn key_lookup_exact_match() {
653        let mut extra = HashMap::new();
654        extra.insert(
655            "other.com".to_string(),
656            DkimDomainKey {
657                selector: "k1".into(),
658                private_key_pem: TEST_RSA_KEY.into(),
659                ..Default::default()
660            },
661        );
662        let cfg = DkimSignConfig {
663            selector: "default".into(),
664            domain: "example.com".into(),
665            private_key_pem: TEST_RSA_KEY.into(),
666            extra_keys: extra,
667            ..Default::default()
668        };
669        let (d, sel, _) = cfg.key_for_from_domain("other.com").unwrap();
670        assert_eq!(d, "other.com");
671        assert_eq!(sel, "k1");
672    }
673
674    #[test]
675    fn key_lookup_suffix_walk() {
676        // From domain `mail.other.com` should resolve to the `other.com`
677        // entry via ancestor-suffix walk (strip leading `mail.` label).
678        let mut extra = HashMap::new();
679        extra.insert(
680            "other.com".to_string(),
681            DkimDomainKey {
682                selector: "suffix".into(),
683                private_key_pem: TEST_RSA_KEY.into(),
684                ..Default::default()
685            },
686        );
687        let cfg = DkimSignConfig {
688            selector: "default".into(),
689            domain: "example.com".into(),
690            private_key_pem: TEST_RSA_KEY.into(),
691            extra_keys: extra,
692            ..Default::default()
693        };
694        let (d, sel, _) = cfg.key_for_from_domain("mail.other.com").unwrap();
695        assert_eq!(d, "other.com");
696        assert_eq!(sel, "suffix");
697    }
698
699    #[test]
700    fn key_lookup_suffix_walk_two_levels() {
701        // a.b.org → b.org (still keep the entry).
702        let mut extra = HashMap::new();
703        extra.insert(
704            "b.org".to_string(),
705            DkimDomainKey {
706                selector: "borg".into(),
707                private_key_pem: TEST_RSA_KEY.into(),
708                ..Default::default()
709            },
710        );
711        let cfg = DkimSignConfig {
712            selector: "default".into(),
713            domain: "example.com".into(),
714            private_key_pem: TEST_RSA_KEY.into(),
715            extra_keys: extra,
716            ..Default::default()
717        };
718        let (d, sel, _) = cfg.key_for_from_domain("a.b.org").unwrap();
719        assert_eq!(d, "b.org");
720        assert_eq!(sel, "borg");
721    }
722
723    #[test]
724    fn key_lookup_default_fallback() {
725        // No extra-key match, even via suffix walk → use the default
726        // selector + domain + key.
727        let mut extra = HashMap::new();
728        extra.insert(
729            "other.com".to_string(),
730            DkimDomainKey {
731                selector: "other".into(),
732                private_key_pem: TEST_RSA_KEY.into(),
733                ..Default::default()
734            },
735        );
736        let cfg = DkimSignConfig {
737            selector: "default".into(),
738            domain: "example.com".into(),
739            private_key_pem: TEST_RSA_KEY.into(),
740            extra_keys: extra,
741            ..Default::default()
742        };
743        let (d, sel, _) = cfg.key_for_from_domain("unrelated.net").unwrap();
744        assert_eq!(d, "example.com");
745        assert_eq!(sel, "default");
746    }
747
748    #[test]
749    fn key_lookup_does_not_match_bare_tld() {
750        // Even if someone configures `extra_keys["com"]`, a From of
751        // `alice@something.com` must not pick it up via suffix walk —
752        // signing with d=com would be nonsense. The walk stops at a
753        // dot-less parent.
754        let mut extra = HashMap::new();
755        extra.insert(
756            "com".to_string(),
757            DkimDomainKey {
758                selector: "comkey".into(),
759                private_key_pem: TEST_RSA_KEY.into(),
760                ..Default::default()
761            },
762        );
763        let cfg = DkimSignConfig {
764            selector: "default".into(),
765            domain: "example.com".into(),
766            private_key_pem: TEST_RSA_KEY.into(),
767            extra_keys: extra,
768            ..Default::default()
769        };
770        let (d, sel, _) = cfg.key_for_from_domain("something.com").unwrap();
771        // suffix walk stops because `com` has no dot — fell back to default
772        assert_eq!(d, "example.com");
773        assert_eq!(sel, "default");
774    }
775
776    #[test]
777    fn sign_picks_d_from_from_header_via_extra_keys() {
778        // The point of the v4 multi-domain refactor: a message with
779        // `From: postmaster@mail.example.com` must end up signed with
780        // `d=example.com` when `extra_keys["example.com"]` exists,
781        // not with the default `d=otherdefault.com`.
782        let mut extra = HashMap::new();
783        extra.insert(
784            "example.com".to_string(),
785            DkimDomainKey {
786                selector: "mail".into(),
787                private_key_pem: TEST_RSA_KEY.into(),
788                ..Default::default()
789            },
790        );
791        let cfg = DkimSignConfig {
792            selector: "default".into(),
793            domain: "otherdefault.com".into(),
794            private_key_pem: TEST_RSA_KEY.into(),
795            extra_keys: extra,
796            ..Default::default()
797        };
798        let msg = b"From: postmaster@mail.example.com\r\n\
799                    To: r@example.com\r\n\
800                    Subject: hi\r\n\
801                    Date: Thu, 01 Jan 2026 00:00:00 +0000\r\n\
802                    Message-ID: <abc@mail.example.com>\r\n\
803                    \r\n\
804                    body\r\n";
805        let signed = cfg.sign(msg).unwrap();
806        let s = String::from_utf8_lossy(&signed);
807        assert!(
808            s.contains("d=example.com"),
809            "expected d=example.com (via suffix-walk lookup), got {}",
810            s.lines().next().unwrap_or("")
811        );
812        assert!(
813            !s.contains("d=otherdefault.com"),
814            "default domain should NOT have been used"
815        );
816        assert!(s.contains("s=mail"));
817    }
818}