1use std::collections::HashMap;
11use std::sync::Arc;
12
13use hickory_resolver::TokioResolver;
14use kevy_embedded::{PubsubFrame, Store};
15use mailrs_dkim::HickoryDkimResolver;
16use sqlx::PgPool;
17
18use crate::DeliveryEventSender;
19use crate::dkim_sign::{self, DkimSignConfig};
20use crate::queue::{self, QueuedMessage};
21
22mod delivery;
23mod smtp;
24
25pub use delivery::deliver_domain_static;
26pub use smtp::{TlsPolicy, try_deliver_via_mx, try_deliver_via_mx_with_tls};
27
28#[derive(Debug, Clone)]
30pub struct WorkerConfig {
31 pub poll_interval_secs: u64,
33 pub batch_size: u32,
35 pub max_attempts: u32,
37 pub max_concurrent_domains: usize,
39 pub max_messages_per_connection: usize,
42}
43
44impl Default for WorkerConfig {
45 fn default() -> Self {
46 Self {
47 poll_interval_secs: 30,
48 batch_size: 50,
49 max_attempts: 8,
50 max_concurrent_domains: 8,
51 max_messages_per_connection: 50,
52 }
53 }
54}
55
56pub fn group_by_domain(messages: Vec<QueuedMessage>) -> HashMap<String, Vec<QueuedMessage>> {
58 let mut groups: HashMap<String, Vec<QueuedMessage>> = HashMap::new();
59 for msg in messages {
60 groups.entry(msg.domain.clone()).or_default().push(msg);
61 }
62 groups
63}
64
65pub struct DeliveryWorker {
67 config: WorkerConfig,
68 pool: PgPool,
69 resolver: TokioResolver,
70 hostname: String,
71 dkim: Option<DkimSignConfig>,
72 dkim_resolver: Arc<HickoryDkimResolver>,
76 event_sender: Option<DeliveryEventSender>,
77 kevy_store: Option<Store>,
78}
79
80impl DeliveryWorker {
81 pub fn new(
83 config: WorkerConfig,
84 pool: PgPool,
85 resolver: TokioResolver,
86 hostname: String,
87 ) -> Self {
88 let dkim_resolver = Arc::new(HickoryDkimResolver::new(resolver.clone()));
89
90 Self {
91 config,
92 pool,
93 resolver,
94 hostname,
95 dkim: None,
96 dkim_resolver,
97 event_sender: None,
98 kevy_store: None,
99 }
100 }
101
102 pub fn with_dkim(mut self, dkim: DkimSignConfig) -> Self {
104 self.dkim = Some(dkim);
105 self
106 }
107
108 pub fn with_event_sender(mut self, sender: DeliveryEventSender) -> Self {
110 self.event_sender = Some(sender);
111 self
112 }
113
114 pub fn with_kevy(mut self, store: Store) -> Self {
118 self.kevy_store = Some(store);
119 self
120 }
121
122 pub async fn run(&self, mut shutdown: tokio::sync::watch::Receiver<bool>) {
124 tracing::info!(
125 "delivery worker started (poll_interval={}s)",
126 self.config.poll_interval_secs
127 );
128
129 let mut notify_rx = self.spawn_kevy_listener();
131
132 loop {
133 tokio::select! {
134 _ = tokio::time::sleep(std::time::Duration::from_secs(self.config.poll_interval_secs)) => {}
135 _ = wait_for_notify(&mut notify_rx) => {}
136 _ = shutdown.changed() => {
137 if *shutdown.borrow() {
138 tracing::info!("delivery worker shutting down");
139 return;
140 }
141 }
142 }
143
144 if let Err(e) = self.poll_and_deliver().await {
145 tracing::error!("delivery worker error: {e}");
146 }
147 }
148 }
149
150 fn spawn_kevy_listener(&self) -> Option<tokio::sync::mpsc::Receiver<()>> {
151 let store = self.kevy_store.as_ref()?.clone();
152 let (tx, rx) = tokio::sync::mpsc::channel(16);
153 std::thread::spawn(move || {
157 let sub = store.subscribe(&[b"queue:notify"]);
158 tracing::info!("delivery worker subscribed to queue:notify");
159 while let Ok(frame) = sub.recv() {
160 if matches!(
161 frame,
162 PubsubFrame::Message { .. } | PubsubFrame::Pmessage { .. }
163 ) {
164 let _ = tx.blocking_send(());
165 }
166 }
167 });
168 Some(rx)
169 }
170
171 async fn poll_and_deliver(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
172 let now = chrono::Utc::now().timestamp();
173
174 let recovered = queue::recover_stale_inflight(&self.pool, now).await?;
176 if recovered > 0 {
177 tracing::warn!("recovered {recovered} stale inflight messages");
178 }
179
180 if let Ok(pending) = queue::count_pending(&self.pool).await {
185 metrics::gauge!("mailrs_outbound_queue_depth", "status" => "pending")
186 .set(pending as f64);
187 }
188 if let Ok(inflight) = queue::count_inflight(&self.pool).await {
189 metrics::gauge!("mailrs_outbound_queue_depth", "status" => "inflight")
190 .set(inflight as f64);
191 }
192
193 let messages = queue::claim_for_delivery(&self.pool, now, self.config.batch_size).await?;
200
201 if messages.is_empty() {
202 return Ok(());
203 }
204
205 tracing::info!("claimed {} messages for delivery", messages.len());
206
207 let messages: Vec<QueuedMessage> = if let Some(ref dkim) = self.dkim {
224 use futures_util::stream::{self, StreamExt};
225 let dkim = dkim.clone();
226 let dkim_resolver = self.dkim_resolver.clone();
227 stream::iter(messages)
228 .map(|mut msg| {
229 let dkim = dkim.clone();
230 let dkim_resolver = dkim_resolver.clone();
231 async move {
232 if msg.is_forwarded {
234 match dkim_sign::arc_seal_message(
235 &dkim,
236 dkim_resolver.as_ref(),
237 &msg.message_data,
238 )
239 .await
240 {
241 Ok(sealed) => msg.message_data = sealed,
242 Err(e) => {
243 tracing::warn!("ARC sealing failed for msg {}: {e}", msg.id)
244 }
245 }
246 }
247 let data = std::mem::take(&mut msg.message_data);
251 let dkim_for_sign = dkim.clone();
252 match tokio::task::spawn_blocking(move || dkim_for_sign.sign(&data)).await {
253 Ok(Ok(signed)) => msg.message_data = signed,
254 Ok(Err(e)) => {
255 tracing::warn!("DKIM signing failed for msg {}: {e}", msg.id)
256 }
257 Err(e) => tracing::warn!(
258 "DKIM signing task join failed for msg {}: {e}",
259 msg.id
260 ),
261 }
262 msg
263 }
264 })
265 .buffer_unordered(8)
266 .collect()
267 .await
268 } else {
269 messages
270 };
271
272 let groups = group_by_domain(messages);
273 let pool = self.pool.clone();
274 let semaphore = Arc::new(tokio::sync::Semaphore::new(
275 self.config.max_concurrent_domains,
276 ));
277
278 let mut handles = Vec::new();
279 for (domain, domain_messages) in groups {
280 let sem = semaphore.clone();
281 let pool = pool.clone();
282 let resolver = self.resolver.clone();
283 let hostname = self.hostname.clone();
284 let max_per_conn = self.config.max_messages_per_connection;
285 let event_sender = self.event_sender.clone();
286
287 handles.push(tokio::spawn(async move {
288 let _permit = sem.acquire().await.unwrap();
289 deliver_domain_static(
290 &resolver,
291 &hostname,
292 &domain,
293 domain_messages,
294 &pool,
295 25,
296 max_per_conn,
297 event_sender.as_ref(),
298 )
299 .await;
300 }));
301 }
302
303 for handle in handles {
304 let _ = handle.await;
305 }
306
307 Ok(())
308 }
309}
310
311async fn wait_for_notify(rx: &mut Option<tokio::sync::mpsc::Receiver<()>>) {
313 match rx {
314 Some(r) => {
315 r.recv().await;
316 }
317 None => std::future::pending().await,
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::queue::QueueStatus;
325
326 fn make_msg(id: i64, domain: &str) -> QueuedMessage {
327 QueuedMessage {
328 id,
329 sender: "sender@example.com".into(),
330 recipient: format!("rcpt@{domain}"),
331 domain: domain.into(),
332 message_data: vec![],
333 status: QueueStatus::Pending,
334 attempts: 0,
335 max_attempts: 8,
336 next_retry: 0,
337 last_error: None,
338 message_id: None,
339 created_at: 0,
340 updated_at: 0,
341 is_forwarded: false,
342 }
343 }
344
345 #[test]
346 fn group_by_domain_groups() {
347 let messages = vec![
348 make_msg(1, "a.com"),
349 make_msg(2, "b.com"),
350 make_msg(3, "a.com"),
351 ];
352 let groups = group_by_domain(messages);
353 assert_eq!(groups.len(), 2);
354 assert_eq!(groups["a.com"].len(), 2);
355 assert_eq!(groups["b.com"].len(), 1);
356 }
357
358 #[test]
359 fn group_by_domain_empty() {
360 let groups = group_by_domain(vec![]);
361 assert!(groups.is_empty());
362 }
363
364 #[test]
365 fn delivery_worker_config_defaults() {
366 let cfg = WorkerConfig::default();
367 assert_eq!(cfg.poll_interval_secs, 30);
368 assert_eq!(cfg.batch_size, 50);
369 assert_eq!(cfg.max_attempts, 8);
370 assert_eq!(cfg.max_concurrent_domains, 8);
371 assert_eq!(cfg.max_messages_per_connection, 50);
372 }
373
374 #[test]
375 fn group_by_domain_single_domain() {
376 let messages = vec![
377 make_msg(1, "a.com"),
378 make_msg(2, "a.com"),
379 make_msg(3, "a.com"),
380 ];
381 let groups = group_by_domain(messages);
382 assert_eq!(groups.len(), 1);
383 assert_eq!(groups["a.com"].len(), 3);
384 }
385
386 #[test]
387 fn group_by_domain_preserves_order_within_group() {
388 let messages = vec![
389 make_msg(10, "x.com"),
390 make_msg(20, "y.com"),
391 make_msg(30, "x.com"),
392 ];
393 let groups = group_by_domain(messages);
394 let x_ids: Vec<i64> = groups["x.com"].iter().map(|m| m.id).collect();
395 assert_eq!(x_ids, vec![10, 30]);
396 }
397
398 #[test]
399 fn group_by_domain_many_domains() {
400 let messages: Vec<QueuedMessage> = (0..100)
401 .map(|i| make_msg(i, &format!("domain{}.com", i % 10)))
402 .collect();
403 let groups = group_by_domain(messages);
404 assert_eq!(groups.len(), 10);
405 for v in groups.values() {
406 assert_eq!(v.len(), 10);
407 }
408 }
409
410 #[test]
411 fn worker_config_clone() {
412 let cfg = WorkerConfig::default();
413 let c2 = cfg.clone();
414 assert_eq!(c2.poll_interval_secs, cfg.poll_interval_secs);
415 assert_eq!(c2.batch_size, cfg.batch_size);
416 }
417
418 #[test]
419 fn group_by_domain_message_fields_intact() {
420 let msg = QueuedMessage {
421 id: 99,
422 sender: "orig@example.com".into(),
423 recipient: "dest@target.com".into(),
424 domain: "target.com".into(),
425 message_data: vec![0xde, 0xad],
426 status: QueueStatus::Pending,
427 attempts: 2,
428 max_attempts: 5,
429 next_retry: 12345,
430 last_error: Some("timeout".into()),
431 message_id: Some("mid99".into()),
432 created_at: 111,
433 updated_at: 222,
434 is_forwarded: true,
435 };
436 let groups = group_by_domain(vec![msg]);
437 let got = &groups["target.com"][0];
438 assert_eq!(got.id, 99);
439 assert_eq!(got.sender, "orig@example.com");
440 assert_eq!(got.attempts, 2);
441 assert_eq!(got.message_data, vec![0xde, 0xad]);
442 assert!(got.is_forwarded);
443 assert_eq!(got.last_error, Some("timeout".into()));
444 }
445
446 #[test]
447 fn tls_policy_equality() {
448 assert_eq!(TlsPolicy::Opportunistic, TlsPolicy::Opportunistic);
449 assert_eq!(TlsPolicy::Require, TlsPolicy::Require);
450 assert_ne!(TlsPolicy::Opportunistic, TlsPolicy::Require);
451 }
452
453 #[test]
454 fn tls_policy_debug() {
455 let dbg = format!("{:?}", TlsPolicy::Opportunistic);
456 assert!(dbg.contains("Opportunistic"));
457 let dbg = format!("{:?}", TlsPolicy::Require);
458 assert!(dbg.contains("Require"));
459 }
460
461 #[test]
462 fn tls_policy_clone() {
463 let p = TlsPolicy::Require;
464 let p2 = p;
465 assert_eq!(p, p2);
466 }
467
468 #[test]
469 fn tls_policy_copy_semantics() {
470 let a = TlsPolicy::Opportunistic;
472 let b = a;
473 let c = a; assert_eq!(a, b);
475 assert_eq!(b, c);
476 }
477
478 #[test]
479 fn tls_policy_all_variants_distinct() {
480 let variants = [TlsPolicy::Opportunistic, TlsPolicy::Require];
481 for (i, a) in variants.iter().enumerate() {
482 for (j, b) in variants.iter().enumerate() {
483 if i == j {
484 assert_eq!(a, b);
485 } else {
486 assert_ne!(a, b);
487 }
488 }
489 }
490 }
491
492 #[test]
493 fn worker_config_custom_values() {
494 let cfg = WorkerConfig {
495 poll_interval_secs: 10,
496 batch_size: 100,
497 max_attempts: 3,
498 max_concurrent_domains: 16,
499 max_messages_per_connection: 25,
500 };
501 assert_eq!(cfg.poll_interval_secs, 10);
502 assert_eq!(cfg.batch_size, 100);
503 assert_eq!(cfg.max_attempts, 3);
504 assert_eq!(cfg.max_concurrent_domains, 16);
505 assert_eq!(cfg.max_messages_per_connection, 25);
506 }
507
508 #[test]
509 fn worker_config_debug_format() {
510 let cfg = WorkerConfig::default();
511 let dbg = format!("{:?}", cfg);
512 assert!(dbg.contains("WorkerConfig"));
513 assert!(dbg.contains("poll_interval_secs"));
514 assert!(dbg.contains("batch_size"));
515 }
516
517 #[test]
518 fn group_by_domain_unicode_domains() {
519 let messages = vec![
520 make_msg(1, "xn--e1afmapc.xn--p1ai"), make_msg(2, "xn--e1afmapc.xn--p1ai"),
522 make_msg(3, "example.jp"),
523 ];
524 let groups = group_by_domain(messages);
525 assert_eq!(groups.len(), 2);
526 assert_eq!(groups["xn--e1afmapc.xn--p1ai"].len(), 2);
527 assert_eq!(groups["example.jp"].len(), 1);
528 }
529
530 #[test]
531 fn group_by_domain_all_unique_domains() {
532 let messages: Vec<QueuedMessage> =
533 (0..50).map(|i| make_msg(i, &format!("d{i}.com"))).collect();
534 let groups = group_by_domain(messages);
535 assert_eq!(groups.len(), 50);
536 for v in groups.values() {
537 assert_eq!(v.len(), 1);
538 }
539 }
540
541 #[test]
542 fn group_by_domain_domain_with_subdomains() {
543 let messages = vec![
545 make_msg(1, "example.com"),
546 make_msg(2, "mail.example.com"),
547 make_msg(3, "example.com"),
548 ];
549 let groups = group_by_domain(messages);
550 assert_eq!(groups.len(), 2);
551 assert_eq!(groups["example.com"].len(), 2);
552 assert_eq!(groups["mail.example.com"].len(), 1);
553 }
554
555 fn extract_sender_domain(sender: &str) -> &str {
557 sender.rsplit_once('@').map(|(_, d)| d).unwrap_or("unknown")
558 }
559
560 #[test]
561 fn sender_domain_extraction_normal() {
562 assert_eq!(extract_sender_domain("user@example.com"), "example.com");
563 }
564
565 #[test]
566 fn sender_domain_extraction_no_at() {
567 assert_eq!(extract_sender_domain("noatsign"), "unknown");
568 }
569
570 #[test]
571 fn sender_domain_extraction_multiple_at() {
572 assert_eq!(extract_sender_domain("user@sub@example.com"), "example.com");
574 }
575
576 #[test]
577 fn sender_domain_extraction_empty() {
578 assert_eq!(extract_sender_domain(""), "unknown");
579 }
580
581 #[test]
582 fn sender_domain_extraction_at_only() {
583 assert_eq!(extract_sender_domain("@"), "");
584 }
585
586 #[test]
587 fn dsn_skip_empty_sender() {
588 let msg = make_msg(1, "example.com");
590 assert!(
591 msg.sender != "<>" && !msg.sender.is_empty(),
592 "test setup: msg has a real sender"
593 );
594
595 let empty_sender = "";
597 assert!(empty_sender.is_empty() || empty_sender == "<>");
598
599 let null_sender = "<>";
601 assert!(null_sender.is_empty() || null_sender == "<>");
602 }
603
604 #[test]
605 fn dsn_skip_null_sender() {
606 let null_sender = "<>";
608 let empty_sender = "";
609 let real_sender = "user@example.com";
610
611 assert!(null_sender == "<>" || null_sender.is_empty());
613 assert!(empty_sender == "<>" || empty_sender.is_empty());
614
615 assert!(real_sender != "<>" && !real_sender.is_empty());
617 }
618
619 #[test]
620 fn retry_delay_integration_with_group_delivery() {
621 use crate::retry::retry_delay_secs;
623 for attempt in 0..10u32 {
624 let delay = retry_delay_secs(attempt);
625 assert!(
626 delay >= 60,
627 "delay at attempt {attempt} should be at least 60s"
628 );
629 assert!(
630 delay <= 28800,
631 "delay at attempt {attempt} should be capped at 28800s"
632 );
633 }
634 }
635
636 #[test]
637 fn should_bounce_integration_with_worker_defaults() {
638 use crate::retry::should_bounce;
640 let max = WorkerConfig::default().max_attempts;
641 for attempt in 0..max {
642 assert!(
643 !should_bounce(attempt, max),
644 "attempt {attempt} should not bounce"
645 );
646 }
647 assert!(should_bounce(max, max), "attempt {max} should bounce");
648 assert!(
649 should_bounce(max + 1, max),
650 "attempt {} should bounce",
651 max + 1
652 );
653 }
654
655 #[test]
656 fn make_msg_helper_defaults() {
657 let msg = make_msg(42, "test.org");
658 assert_eq!(msg.id, 42);
659 assert_eq!(msg.domain, "test.org");
660 assert_eq!(msg.recipient, "rcpt@test.org");
661 assert_eq!(msg.sender, "sender@example.com");
662 assert_eq!(msg.status, QueueStatus::Pending);
663 assert_eq!(msg.attempts, 0);
664 assert_eq!(msg.max_attempts, 8);
665 assert!(!msg.is_forwarded);
666 assert!(msg.last_error.is_none());
667 assert!(msg.message_id.is_none());
668 }
669
670 #[test]
671 fn group_by_domain_large_batch() {
672 let batch_size = WorkerConfig::default().batch_size;
674 let messages: Vec<QueuedMessage> = (0..batch_size as i64)
675 .map(|i| make_msg(i, &format!("domain{}.com", i % 5)))
676 .collect();
677 let groups = group_by_domain(messages);
678 assert_eq!(groups.len(), 5);
679 let total: usize = groups.values().map(|v| v.len()).sum();
680 assert_eq!(total, batch_size as usize);
681 }
682
683 #[test]
684 fn group_by_domain_ids_are_all_present() {
685 let messages = vec![
686 make_msg(100, "a.com"),
687 make_msg(200, "b.com"),
688 make_msg(300, "a.com"),
689 make_msg(400, "c.com"),
690 make_msg(500, "b.com"),
691 ];
692 let groups = group_by_domain(messages);
693 let mut all_ids: Vec<i64> = groups
694 .values()
695 .flat_map(|v| v.iter().map(|m| m.id))
696 .collect();
697 all_ids.sort();
698 assert_eq!(all_ids, vec![100, 200, 300, 400, 500]);
699 }
700}