1use 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#[derive(Debug, Clone, Default)]
22pub struct DkimDomainKey {
23 pub selector: String,
25 pub private_key_pem: String,
27 #[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#[derive(Debug, Clone, Default)]
64pub struct DkimSignConfig {
65 pub selector: String,
69 pub domain: String,
72 pub private_key_pem: String,
74 #[doc(hidden)]
82 pub parsed_key: Arc<OnceLock<Result<RsaSigningKey, String>>>,
83 pub extra_keys: HashMap<String, DkimDomainKey>,
91}
92
93impl DkimSignConfig {
94 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 fn key_for_from_domain(
124 &self,
125 from_domain: &str,
126 ) -> Result<(String, String, RsaSigningKey), String> {
127 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 let mut rest = from_domain;
139 while let Some(idx) = rest.find('.') {
140 let parent = &rest[idx + 1..];
141 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 Ok((
157 self.domain.clone(),
158 self.selector.clone(),
159 self.rsa_key()?.clone(),
160 ))
161 }
162
163 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
204pub 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 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
231pub fn extract_domain(email: &str) -> Option<&str> {
233 email.rsplit_once('@').map(|(_, domain)| domain)
234}
235
236pub async fn arc_seal_message(
239 dkim_config: &DkimSignConfig,
240 dkim_resolver: &HickoryDkimResolver,
241 message: &[u8],
242) -> Result<Vec<u8>, String> {
243 let dkim_outputs = verify_all(dkim_resolver, message).await;
245
246 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 let authres = build_authres_body(&dkim_config.domain, &dkim_outputs);
266
267 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 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 #[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 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 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 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 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 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 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}