Skip to main content

mailrs_outbound_queue/worker/
mod.rs

1//! Background delivery worker: polls the outbound queue, signs, and
2//! delivers to remote MX hosts.
3//!
4//! Sub-modules:
5//! - [`delivery`] — per-domain orchestration (MX resolve, retry/bounce,
6//!   DSN enqueue).
7//! - [`smtp`] — per-MX SMTP delivery with STARTTLS / DANE policy
8//!   handling. [`TlsPolicy`] is re-exported from here.
9
10use 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/// Delivery worker configuration.
29#[derive(Debug, Clone)]
30pub struct WorkerConfig {
31    /// Polling cadence when no Kevy notify wakeup is available.
32    pub poll_interval_secs: u64,
33    /// Max queue rows fetched per poll tick.
34    pub batch_size: u32,
35    /// Cap on retry attempts before a row flips to `Bounced`.
36    pub max_attempts: u32,
37    /// Max concurrent destination domains delivered in parallel.
38    pub max_concurrent_domains: usize,
39    /// Max messages reused on a single SMTP connection (RFC 5321
40    /// recommends pipelining).
41    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
56/// group queued messages by target domain for efficient delivery
57pub 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
65/// background delivery worker that polls the queue and delivers messages
66pub struct DeliveryWorker {
67    config: WorkerConfig,
68    pool: PgPool,
69    resolver: TokioResolver,
70    hostname: String,
71    dkim: Option<DkimSignConfig>,
72    /// DKIM/ARC verify resolver — reuses the same hickory binding as
73    /// `resolver`, wrapped in the shape `mailrs-dkim` / `mailrs-arc`
74    /// expect. Used by ARC sealing for the verify-then-seal flow.
75    dkim_resolver: Arc<HickoryDkimResolver>,
76    event_sender: Option<DeliveryEventSender>,
77    kevy_store: Option<Store>,
78}
79
80impl DeliveryWorker {
81    /// Construct a delivery worker with the given config + dependencies.
82    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    /// Configure DKIM signing for outbound messages.
103    pub fn with_dkim(mut self, dkim: DkimSignConfig) -> Self {
104        self.dkim = Some(dkim);
105        self
106    }
107
108    /// Attach a [`DeliveryEventSender`] callback for external observers.
109    pub fn with_event_sender(mut self, sender: DeliveryEventSender) -> Self {
110        self.event_sender = Some(sender);
111        self
112    }
113
114    /// Attach an in-process kevy [`Store`] to subscribe to `queue:notify`
115    /// for fast wakeup. `Store: Clone` so callers typically pass a clone of
116    /// the shared cement-owned store.
117    pub fn with_kevy(mut self, store: Store) -> Self {
118        self.kevy_store = Some(store);
119        self
120    }
121
122    /// Run the worker loop until `shutdown` signals.
123    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        // try to subscribe to Kevy queue:notify for fast wakeup
130        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        // Dedicated OS thread blocks on the sync `Subscription::recv` for
154        // the worker's lifetime — small + long-lived, so we avoid the
155        // tokio blocking-pool slot.
156        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        // recover stale inflight messages (worker crash recovery)
175        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        // Publish queue-depth gauges every poll tick. Cheap: two
181        // count(*) on an indexed `status` column. Lets ops dashboards
182        // alert on a queue that won't drain (delivery wedged) or one
183        // that's growing faster than we can flush.
184        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        // Atomic SKIP LOCKED claim + inflight transition in one
194        // statement: collapses the previous SELECT + N per-row
195        // UPDATEs (N+1 roundtrips, N+1 WAL fsyncs) into a single
196        // roundtrip and single fsync per batch, and prevents
197        // duplicate delivery in multi-worker setups (each pending
198        // row goes to at most one worker).
199        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        // apply ARC sealing (for forwarded messages) + DKIM signing.
208        //
209        // Both steps are independent across messages. ARC sealing is
210        // async (awaits DNS lookups when reconstructing the
211        // authentication-results chain); DKIM signing is CPU-bound
212        // (RSA-SHA256 over the canonicalised message). Previously
213        // these ran one-after-another for the whole batch, paying
214        // ARC's DNS RTT × N + RSA-sign × N sequentially.
215        //
216        // Now: `buffer_unordered(8)` runs up to 8 messages' ARC+DKIM
217        // concurrently. DKIM sign goes through `spawn_blocking` to
218        // keep CPU work off the tokio reactor thread — multiple
219        // signs can land on different blocking-pool threads. The
220        // ordering of the returned batch is no longer guaranteed,
221        // which is fine because each `QueuedMessage` is independent
222        // and the downstream `group_by_domain` re-sorts anyway.
223        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                        // ARC seal forwarded messages before DKIM signing.
233                        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                        // DKIM sign: RSA-SHA256 is CPU-bound; run on
248                        // the blocking pool so the reactor stays
249                        // responsive for the other in-flight signs.
250                        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
311/// wait for a Kevy notify signal, or never resolve if no listener
312async 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        // TlsPolicy is Copy — original is still usable after assignment
471        let a = TlsPolicy::Opportunistic;
472        let b = a;
473        let c = a; // a still usable after copy to b
474        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"), // punycode domain
521            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        // subdomains are distinct from parent domain
544        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    /// helper: extract sender domain the same way enqueue_dsn does
556    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        // rsplit_once splits at the last @
573        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        // enqueue_dsn skips when sender is empty — verify the condition
589        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        // empty sender should be skipped
596        let empty_sender = "";
597        assert!(empty_sender.is_empty() || empty_sender == "<>");
598
599        // null sender should be skipped
600        let null_sender = "<>";
601        assert!(null_sender.is_empty() || null_sender == "<>");
602    }
603
604    #[test]
605    fn dsn_skip_null_sender() {
606        // the "<>" check prevents infinite bounce loops (RFC 3461)
607        let null_sender = "<>";
608        let empty_sender = "";
609        let real_sender = "user@example.com";
610
611        // should skip (bounce-of-bounce prevention)
612        assert!(null_sender == "<>" || null_sender.is_empty());
613        assert!(empty_sender == "<>" || empty_sender.is_empty());
614
615        // should not skip
616        assert!(real_sender != "<>" && !real_sender.is_empty());
617    }
618
619    #[test]
620    fn retry_delay_integration_with_group_delivery() {
621        // verify retry delay for each attempt matches what the worker uses
622        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        // with default max_attempts=8, bounces start at attempt 8
639        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        // simulate a realistic batch size matching worker config
673        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}