1use std::future::Future;
4use std::time::Duration;
5
6#[cfg(feature = "persistence")]
7use sea_orm::entity::prelude::*;
8use serde::{Deserialize, Serialize};
9#[cfg(all(debug_assertions, feature = "openapi"))]
10use utoipa::ToSchema;
11
12pub const DEFAULT_ERROR_MAX_LEN: usize = 1024;
14
15pub const DEFAULT_MARK_SENT_RETRY_DELAYS_MS: &[u64] = &[0, 100, 500, 2_000, 5_000];
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[cfg_attr(feature = "persistence", derive(EnumIter, DeriveActiveEnum))]
30#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
31#[cfg_attr(
32 feature = "persistence",
33 sea_orm(rs_type = "String", db_type = "String(StringLen::N(64))")
34)]
35#[serde(rename_all = "snake_case")]
36pub enum MailTemplateCode {
37 #[cfg_attr(feature = "persistence", sea_orm(string_value = "register_activation"))]
39 RegisterActivation,
40 #[cfg_attr(
42 feature = "persistence",
43 sea_orm(string_value = "contact_change_confirmation")
44 )]
45 ContactChangeConfirmation,
46 #[cfg_attr(feature = "persistence", sea_orm(string_value = "password_reset"))]
48 PasswordReset,
49 #[cfg_attr(
51 feature = "persistence",
52 sea_orm(string_value = "password_reset_notice")
53 )]
54 PasswordResetNotice,
55 #[cfg_attr(
57 feature = "persistence",
58 sea_orm(string_value = "contact_change_notice")
59 )]
60 ContactChangeNotice,
61 #[cfg_attr(
63 feature = "persistence",
64 sea_orm(string_value = "external_auth_email_verification")
65 )]
66 ExternalAuthEmailVerification,
67 #[cfg_attr(feature = "persistence", sea_orm(string_value = "login_email_code"))]
69 LoginEmailCode,
70 #[cfg_attr(feature = "persistence", sea_orm(string_value = "user_invitation"))]
72 UserInvitation,
73}
74
75impl MailTemplateCode {
76 #[must_use]
78 pub const fn as_str(self) -> &'static str {
79 match self {
80 Self::RegisterActivation => "register_activation",
81 Self::ContactChangeConfirmation => "contact_change_confirmation",
82 Self::PasswordReset => "password_reset",
83 Self::PasswordResetNotice => "password_reset_notice",
84 Self::ContactChangeNotice => "contact_change_notice",
85 Self::ExternalAuthEmailVerification => "external_auth_email_verification",
86 Self::LoginEmailCode => "login_email_code",
87 Self::UserInvitation => "user_invitation",
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[cfg_attr(feature = "persistence", derive(DeriveValueType))]
95pub struct StoredMailPayload(pub String);
96
97impl StoredMailPayload {
98 pub const CLEARED_JSON: &str = "{}";
101
102 #[must_use]
104 pub fn cleared() -> Self {
105 Self(Self::CLEARED_JSON.to_string())
106 }
107}
108
109impl AsRef<str> for StoredMailPayload {
110 fn as_ref(&self) -> &str {
111 &self.0
112 }
113}
114
115impl From<String> for StoredMailPayload {
116 fn from(value: String) -> Self {
117 Self(value)
118 }
119}
120
121impl From<StoredMailPayload> for String {
122 fn from(value: StoredMailPayload) -> Self {
123 value.0
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[cfg_attr(feature = "persistence", derive(EnumIter, DeriveActiveEnum))]
130#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
131#[cfg_attr(
132 feature = "persistence",
133 sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")
134)]
135#[serde(rename_all = "snake_case")]
136pub enum MailOutboxStatus {
137 #[cfg_attr(feature = "persistence", sea_orm(string_value = "pending"))]
139 Pending,
140 #[cfg_attr(feature = "persistence", sea_orm(string_value = "processing"))]
142 Processing,
143 #[cfg_attr(feature = "persistence", sea_orm(string_value = "retry"))]
145 Retry,
146 #[cfg_attr(feature = "persistence", sea_orm(string_value = "sent"))]
148 Sent,
149 #[cfg_attr(feature = "persistence", sea_orm(string_value = "failed"))]
151 Failed,
152}
153
154impl MailOutboxStatus {
155 #[must_use]
157 pub const fn is_terminal(self) -> bool {
158 matches!(self, Self::Sent | Self::Failed)
159 }
160}
161
162#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
164pub struct DispatchStats {
165 pub claimed: usize,
167 pub sent: usize,
169 pub retried: usize,
171 pub failed: usize,
173}
174
175impl DispatchStats {
176 pub fn merge(&mut self, other: Self) {
178 self.claimed += other.claimed;
179 self.sent += other.sent;
180 self.retried += other.retried;
181 self.failed += other.failed;
182 }
183
184 #[must_use]
186 pub const fn is_empty(self) -> bool {
187 self.claimed == 0 && self.sent == 0 && self.retried == 0 && self.failed == 0
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct MailOutboxDispatchContext {
198 pub id: i64,
200 pub attempt_count: i32,
202 pub template_code: String,
204 pub to_address: String,
206 pub to_name: Option<String>,
208}
209
210impl MailOutboxDispatchContext {
211 #[must_use]
213 pub const fn delivery_attempt_count(&self) -> i32 {
214 self.attempt_count + 1
215 }
216}
217
218pub trait MailOutboxDispatchRow {
223 fn id(&self) -> i64;
225
226 fn attempt_count(&self) -> i32;
228
229 fn template_code(&self) -> &str;
231
232 fn to_address(&self) -> &str;
234
235 fn to_name(&self) -> Option<&str> {
237 None
238 }
239
240 fn dispatch_context(&self) -> MailOutboxDispatchContext {
242 MailOutboxDispatchContext {
243 id: self.id(),
244 attempt_count: self.attempt_count(),
245 template_code: self.template_code().to_string(),
246 to_address: self.to_address().to_string(),
247 to_name: self.to_name().map(str::to_string),
248 }
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct MailOutboxDispatchConfig {
255 pub batch_size: u64,
257 pub processing_stale_secs: i64,
259 pub drain_max_rounds: usize,
261 pub retry_policy: MailOutboxRetryPolicy,
263 pub mark_sent_retry_delays_ms: &'static [u64],
265}
266
267impl MailOutboxDispatchConfig {
268 #[must_use]
270 pub const fn new(
271 batch_size: u64,
272 processing_stale_secs: i64,
273 drain_max_rounds: usize,
274 retry_policy: MailOutboxRetryPolicy,
275 ) -> Self {
276 Self {
277 batch_size,
278 processing_stale_secs,
279 drain_max_rounds,
280 retry_policy,
281 mark_sent_retry_delays_ms: DEFAULT_MARK_SENT_RETRY_DELAYS_MS,
282 }
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct MailOutboxRetryPolicy {
289 pub max_attempts: i32,
291 pub max_error_len: usize,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum MailOutboxDeliveryFailureDecision {
298 PermanentFailure {
300 attempt_count: i32,
302 error_message: String,
304 },
305 Retry {
307 attempt_count: i32,
309 retry_delay_secs: i64,
311 error_message: String,
313 },
314}
315
316impl MailOutboxRetryPolicy {
317 #[must_use]
319 pub const fn new(max_attempts: i32, max_error_len: usize) -> Self {
320 Self {
321 max_attempts,
322 max_error_len,
323 }
324 }
325
326 #[must_use]
328 pub const fn should_permanently_fail(&self, attempt_count: i32) -> bool {
329 attempt_count >= self.max_attempts
330 }
331
332 #[must_use]
334 pub const fn retry_delay_secs(&self, attempt_count: i32) -> i64 {
335 retry_delay_secs(attempt_count)
336 }
337
338 #[must_use]
340 pub fn truncate_error(&self, error: &str) -> String {
341 truncate_error(error, self.max_error_len)
342 }
343
344 pub fn delivery_failure_decision(
349 &self,
350 attempt_count: i32,
351 error: impl AsRef<str>,
352 ) -> MailOutboxDeliveryFailureDecision {
353 let error_message = self.truncate_error(error.as_ref());
354 if self.should_permanently_fail(attempt_count) {
355 MailOutboxDeliveryFailureDecision::PermanentFailure {
356 attempt_count,
357 error_message,
358 }
359 } else {
360 MailOutboxDeliveryFailureDecision::Retry {
361 attempt_count,
362 retry_delay_secs: self.retry_delay_secs(attempt_count),
363 error_message,
364 }
365 }
366 }
367}
368
369#[must_use]
371pub const fn retry_delay_secs(attempt_count: i32) -> i64 {
372 match attempt_count {
373 1 => 5,
374 2 => 15,
375 3 => 60,
376 4 => 300,
377 5 => 900,
378 _ => 1800,
379 }
380}
381
382#[must_use]
384pub fn truncate_error(error: &str, max_len: usize) -> String {
385 error.chars().take(max_len).collect()
386}
387
388#[expect(
393 clippy::too_many_arguments,
394 clippy::too_many_lines,
395 reason = "The dispatcher keeps claim, delivery, outcome persistence, retry, and audit callbacks in one fenced dispatch transaction."
396)]
397pub async fn dispatch_mail_outbox<
402 R,
403 E,
404 List,
405 ListFut,
406 Claim,
407 ClaimFut,
408 Deliver,
409 DeliverFut,
410 MarkSent,
411 MarkSentFut,
412 MarkRetry,
413 MarkRetryFut,
414 MarkFailed,
415 MarkFailedFut,
416 OnSent,
417 OnSentFut,
418 OnFailed,
419 OnFailedFut,
420>(
421 config: &MailOutboxDispatchConfig,
422 mut list_claimable: List,
423 mut try_claim: Claim,
424 mut deliver: Deliver,
425 mut mark_sent: MarkSent,
426 mut mark_retry: MarkRetry,
427 mut mark_failed: MarkFailed,
428 mut on_sent: OnSent,
429 mut on_failed: OnFailed,
430) -> Result<DispatchStats, E>
431where
432 R: MailOutboxDispatchRow,
433 E: std::fmt::Display,
434 List: FnMut(u64, i64) -> ListFut,
435 ListFut: Future<Output = Result<Vec<R>, E>>,
436 Claim: FnMut(i64) -> ClaimFut,
437 ClaimFut: Future<Output = Result<bool, E>>,
438 Deliver: FnMut(R) -> DeliverFut,
439 DeliverFut: Future<Output = Result<String, E>>,
440 MarkSent: FnMut(i64, usize) -> MarkSentFut,
441 MarkSentFut: Future<Output = Result<bool, E>>,
442 MarkRetry: FnMut(i64, i32, i64, String) -> MarkRetryFut,
443 MarkRetryFut: Future<Output = Result<bool, E>>,
444 MarkFailed: FnMut(i64, i32, String) -> MarkFailedFut,
445 MarkFailedFut: Future<Output = Result<bool, E>>,
446 OnSent: FnMut(MailOutboxDispatchContext, i32, String) -> OnSentFut,
447 OnSentFut: Future<Output = ()>,
448 OnFailed: FnMut(MailOutboxDispatchContext, i32, String) -> OnFailedFut,
449 OnFailedFut: Future<Output = ()>,
450{
451 let rows = list_claimable(config.batch_size, config.processing_stale_secs).await?;
452 let mut stats = DispatchStats::default();
453 tracing::debug!(
454 batch_size = config.batch_size,
455 due_count = rows.len(),
456 stale_secs = config.processing_stale_secs,
457 "dispatching due mail outbox rows"
458 );
459
460 for row in rows {
461 let context = row.dispatch_context();
462 if !try_claim(context.id).await? {
463 tracing::debug!(
464 mail_outbox_id = context.id,
465 template_code = %context.template_code,
466 "mail outbox claim skipped because row was already claimed"
467 );
468 continue;
469 }
470
471 stats.claimed += 1;
472 tracing::debug!(
473 mail_outbox_id = context.id,
474 template_code = %context.template_code,
475 attempt_count = context.attempt_count,
476 "claimed mail outbox row"
477 );
478
479 match deliver(row).await {
480 Ok(subject) => {
481 tracing::debug!(
482 mail_outbox_id = context.id,
483 template_code = %context.template_code,
484 "mail outbox delivery succeeded"
485 );
486 match retry_mark_sent(context.id, config.mark_sent_retry_delays_ms, &mut mark_sent)
487 .await
488 {
489 Ok(true) => {
490 stats.sent += 1;
491 let attempt_count = context.delivery_attempt_count();
492 on_sent(context, attempt_count, subject).await;
493 }
494 Ok(false) => {
495 tracing::warn!(
496 mail_outbox_id = context.id,
497 template_code = %context.template_code,
498 to = %context.to_address,
499 "mark_sent affected 0 rows after successful delivery; state will be rechecked"
500 );
501 }
502 Err(error) => {
503 tracing::error!(
504 mail_outbox_id = context.id,
505 template_code = %context.template_code,
506 to = %context.to_address,
507 stale_secs = config.processing_stale_secs,
508 error = %error,
509 "CRITICAL: mail delivery succeeded but mark_sent failed after all retries; \
510 row remains Processing and may be re-claimed, causing duplicate delivery"
511 );
512 }
513 }
514 }
515 Err(error) => {
516 let attempt_count = context.delivery_attempt_count();
517 match config
518 .retry_policy
519 .delivery_failure_decision(attempt_count, error.to_string())
520 {
521 MailOutboxDeliveryFailureDecision::PermanentFailure {
522 attempt_count,
523 error_message,
524 } => {
525 if mark_failed(context.id, attempt_count, error_message.clone()).await? {
526 stats.failed += 1;
527 on_failed(context.clone(), attempt_count, error_message.clone()).await;
528 }
529 tracing::warn!(
530 mail_outbox_id = context.id,
531 template_code = %context.template_code,
532 to = %context.to_address,
533 attempt_count,
534 error = %error_message,
535 "mail outbox delivery permanently failed"
536 );
537 }
538 MailOutboxDeliveryFailureDecision::Retry {
539 attempt_count,
540 retry_delay_secs,
541 error_message,
542 } => {
543 if mark_retry(
544 context.id,
545 attempt_count,
546 retry_delay_secs,
547 error_message.clone(),
548 )
549 .await?
550 {
551 stats.retried += 1;
552 }
553 tracing::warn!(
554 mail_outbox_id = context.id,
555 template_code = %context.template_code,
556 to = %context.to_address,
557 attempt_count,
558 retry_delay_secs,
559 error = %error_message,
560 "mail outbox delivery failed; scheduled retry"
561 );
562 }
563 }
564 }
565 }
566 }
567
568 tracing::debug!(
569 claimed = stats.claimed,
570 sent = stats.sent,
571 retried = stats.retried,
572 failed = stats.failed,
573 "finished dispatching due mail outbox rows"
574 );
575 Ok(stats)
576}
577
578pub async fn drain_mail_outbox<E, Dispatch, DispatchFut>(
584 config: &MailOutboxDispatchConfig,
585 mut dispatch: Dispatch,
586) -> Result<DispatchStats, E>
587where
588 E: std::fmt::Display,
589 Dispatch: FnMut() -> DispatchFut,
590 DispatchFut: Future<Output = Result<DispatchStats, E>>,
591{
592 let mut total = DispatchStats::default();
593 tracing::debug!("draining mail outbox");
594
595 for _ in 0..config.drain_max_rounds {
596 let stats = dispatch().await?;
597 let claimed = stats.claimed;
598 total.merge(stats);
599 if claimed == 0 {
600 tracing::debug!("mail outbox drain finished because no rows were claimed");
601 break;
602 }
603 }
604
605 tracing::debug!(
606 claimed = total.claimed,
607 sent = total.sent,
608 retried = total.retried,
609 failed = total.failed,
610 "mail outbox drain completed"
611 );
612 Ok(total)
613}
614
615pub async fn retry_mark_sent<F, Fut, E>(
626 id: i64,
627 retry_delays_ms: &[u64],
628 mut mark_sent: F,
629) -> Result<bool, E>
630where
631 F: FnMut(i64, usize) -> Fut,
632 Fut: Future<Output = Result<bool, E>>,
633 E: std::fmt::Display,
634{
635 let mut last_err = None;
636 for (index, delay_ms) in retry_delays_ms.iter().enumerate() {
637 if *delay_ms > 0 {
638 tokio::time::sleep(Duration::from_millis(*delay_ms)).await;
639 }
640
641 let attempt = index + 1;
642 match mark_sent(id, attempt).await {
643 Ok(updated) => {
644 tracing::debug!(
645 mail_outbox_id = id,
646 attempt,
647 updated,
648 "marked mail outbox row as sent"
649 );
650 return Ok(updated);
651 }
652 Err(error) => {
653 tracing::warn!(
654 mail_outbox_id = id,
655 attempt,
656 "mark_sent failed, will retry: {error}"
657 );
658 last_err = Some(error);
659 }
660 }
661 }
662
663 match last_err {
664 Some(error) => Err(error),
665 None => mark_sent(id, retry_delays_ms.len() + 1).await,
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::{
672 DEFAULT_ERROR_MAX_LEN, DispatchStats, MailOutboxDispatchConfig, MailOutboxDispatchContext,
673 MailOutboxDispatchRow, MailOutboxRetryPolicy, MailOutboxStatus, MailTemplateCode,
674 StoredMailPayload, dispatch_mail_outbox, retry_delay_secs, retry_mark_sent, truncate_error,
675 };
676 use std::sync::{
677 Arc,
678 atomic::{AtomicUsize, Ordering},
679 };
680
681 #[test]
682 fn dispatch_stats_merge_adds_all_counters() {
683 let mut stats = DispatchStats {
684 claimed: 1,
685 sent: 2,
686 retried: 3,
687 failed: 4,
688 };
689 stats.merge(DispatchStats {
690 claimed: 10,
691 sent: 20,
692 retried: 30,
693 failed: 40,
694 });
695
696 assert_eq!(
697 stats,
698 DispatchStats {
699 claimed: 11,
700 sent: 22,
701 retried: 33,
702 failed: 44,
703 }
704 );
705 assert!(!stats.is_empty());
706 assert!(DispatchStats::default().is_empty());
707 }
708
709 #[test]
710 fn retry_policy_matches_default_mail_backoff() {
711 let policy = MailOutboxRetryPolicy::new(6, DEFAULT_ERROR_MAX_LEN);
712
713 assert!(!policy.should_permanently_fail(5));
714 assert!(policy.should_permanently_fail(6));
715 assert_eq!(policy.retry_delay_secs(1), 5);
716 assert_eq!(policy.retry_delay_secs(2), 15);
717 assert_eq!(policy.retry_delay_secs(3), 60);
718 assert_eq!(policy.retry_delay_secs(4), 300);
719 assert_eq!(policy.retry_delay_secs(5), 900);
720 assert_eq!(retry_delay_secs(99), 1800);
721 }
722
723 #[test]
724 fn retry_policy_classifies_delivery_failures() {
725 let policy = MailOutboxRetryPolicy::new(2, 3);
726
727 assert_eq!(
728 policy.delivery_failure_decision(1, "abcdef"),
729 super::MailOutboxDeliveryFailureDecision::Retry {
730 attempt_count: 1,
731 retry_delay_secs: 5,
732 error_message: "abc".to_string(),
733 }
734 );
735 assert_eq!(
736 policy.delivery_failure_decision(2, "abcdef"),
737 super::MailOutboxDeliveryFailureDecision::PermanentFailure {
738 attempt_count: 2,
739 error_message: "abc".to_string(),
740 }
741 );
742 }
743
744 #[test]
745 fn truncate_error_preserves_utf8_boundaries() {
746 let value = "界".repeat(4);
747 assert_eq!(truncate_error(&value, 3), "界界界");
748 }
749
750 #[test]
751 fn mail_template_code_exposes_stable_storage_names() {
752 assert_eq!(
753 MailTemplateCode::RegisterActivation.as_str(),
754 "register_activation"
755 );
756 assert_eq!(
757 MailTemplateCode::ContactChangeConfirmation.as_str(),
758 "contact_change_confirmation"
759 );
760 assert_eq!(MailTemplateCode::PasswordReset.as_str(), "password_reset");
761 assert_eq!(
762 MailTemplateCode::PasswordResetNotice.as_str(),
763 "password_reset_notice"
764 );
765 assert_eq!(
766 MailTemplateCode::ContactChangeNotice.as_str(),
767 "contact_change_notice"
768 );
769 assert_eq!(
770 MailTemplateCode::ExternalAuthEmailVerification.as_str(),
771 "external_auth_email_verification"
772 );
773 assert_eq!(
774 MailTemplateCode::LoginEmailCode.as_str(),
775 "login_email_code"
776 );
777 assert_eq!(MailTemplateCode::UserInvitation.as_str(), "user_invitation");
778 }
779
780 #[test]
781 fn mail_template_code_storage_names_fit_shared_schema() {
782 let codes = [
783 MailTemplateCode::RegisterActivation,
784 MailTemplateCode::ContactChangeConfirmation,
785 MailTemplateCode::PasswordReset,
786 MailTemplateCode::PasswordResetNotice,
787 MailTemplateCode::ContactChangeNotice,
788 MailTemplateCode::ExternalAuthEmailVerification,
789 MailTemplateCode::LoginEmailCode,
790 MailTemplateCode::UserInvitation,
791 ];
792
793 for code in codes {
794 assert!(
795 code.as_str().len() <= 64,
796 "mail template code `{}` exceeds shared schema length",
797 code.as_str()
798 );
799 }
800 }
801
802 #[test]
803 fn stored_mail_payload_helpers_preserve_raw_json() {
804 let payload = StoredMailPayload::from("{\"token\":\"abc\"}".to_string());
805 assert_eq!(payload.as_ref(), "{\"token\":\"abc\"}");
806
807 let raw: String = payload.into();
808 assert_eq!(raw, "{\"token\":\"abc\"}");
809 assert_eq!(StoredMailPayload::cleared().as_ref(), "{}");
810 }
811
812 #[test]
813 fn mail_outbox_status_terminal_states_are_explicit() {
814 assert!(!MailOutboxStatus::Pending.is_terminal());
815 assert!(!MailOutboxStatus::Processing.is_terminal());
816 assert!(!MailOutboxStatus::Retry.is_terminal());
817 assert!(MailOutboxStatus::Sent.is_terminal());
818 assert!(MailOutboxStatus::Failed.is_terminal());
819 }
820
821 #[derive(Debug)]
822 struct NonCloneDispatchRow {
823 id: i64,
824 attempt_count: i32,
825 template_code: String,
826 to_address: String,
827 to_name: Option<String>,
828 payload_json: String,
829 }
830
831 impl MailOutboxDispatchRow for NonCloneDispatchRow {
832 fn id(&self) -> i64 {
833 self.id
834 }
835
836 fn attempt_count(&self) -> i32 {
837 self.attempt_count
838 }
839
840 fn template_code(&self) -> &str {
841 &self.template_code
842 }
843
844 fn to_address(&self) -> &str {
845 &self.to_address
846 }
847
848 fn to_name(&self) -> Option<&str> {
849 self.to_name.as_deref()
850 }
851 }
852
853 #[tokio::test]
854 async fn dispatch_mail_outbox_does_not_require_cloning_rows() {
855 let delivered_payload_len = Arc::new(AtomicUsize::new(0));
856 let delivered_payload_len_for_deliver = delivered_payload_len.clone();
857 let sent_context = Arc::new(std::sync::Mutex::new(None::<MailOutboxDispatchContext>));
858 let sent_context_for_hook = sent_context.clone();
859 let config = MailOutboxDispatchConfig::new(
860 20,
861 60,
862 1,
863 MailOutboxRetryPolicy::new(3, DEFAULT_ERROR_MAX_LEN),
864 );
865
866 let stats = dispatch_mail_outbox(
867 &config,
868 |_batch_size, _stale_secs| async {
869 Ok::<_, String>(vec![NonCloneDispatchRow {
870 id: 7,
871 attempt_count: 0,
872 template_code: "login_email_code".to_string(),
873 to_address: "operator@example.com".to_string(),
874 to_name: Some("Operator".to_string()),
875 payload_json: "x".repeat(4096),
876 }])
877 },
878 |id| async move { Ok::<_, String>(id == 7) },
879 move |row| {
880 let delivered_payload_len = delivered_payload_len_for_deliver.clone();
881 async move {
882 delivered_payload_len.store(row.payload_json.len(), Ordering::SeqCst);
883 Ok::<_, String>("Subject".to_string())
884 }
885 },
886 |_id, _attempt| async { Ok::<_, String>(true) },
887 |_id, _attempt_count, _retry_delay_secs, _error_message| async {
888 Ok::<_, String>(true)
889 },
890 |_id, _attempt_count, _error_message| async { Ok::<_, String>(true) },
891 move |context, _attempt_count, _subject| {
892 let sent_context = sent_context_for_hook.clone();
893 async move {
894 *sent_context
895 .lock()
896 .expect("sent context mutex should not be poisoned") = Some(context);
897 }
898 },
899 |_context, _attempt_count, _error_message| async {},
900 )
901 .await
902 .expect("dispatch should succeed");
903
904 assert_eq!(
905 stats,
906 DispatchStats {
907 claimed: 1,
908 sent: 1,
909 retried: 0,
910 failed: 0,
911 }
912 );
913 assert_eq!(delivered_payload_len.load(Ordering::SeqCst), 4096);
914 let context = sent_context
915 .lock()
916 .expect("sent context mutex should not be poisoned")
917 .clone()
918 .expect("sent hook should receive context");
919 assert_eq!(context.id, 7);
920 assert_eq!(context.template_code, "login_email_code");
921 assert_eq!(context.to_address, "operator@example.com");
922 assert_eq!(context.to_name.as_deref(), Some("Operator"));
923 }
924
925 #[tokio::test]
926 async fn retry_mark_sent_retries_until_success() {
927 let attempts = Arc::new(AtomicUsize::new(0));
928 let attempts_for_closure = attempts.clone();
929
930 let updated = retry_mark_sent(42, &[0, 0, 0], move |_id, _attempt| {
931 let attempts = attempts_for_closure.clone();
932 async move {
933 let current = attempts.fetch_add(1, Ordering::SeqCst) + 1;
934 if current < 3 {
935 Err("temporary db error")
936 } else {
937 Ok(true)
938 }
939 }
940 })
941 .await
942 .expect("mark_sent should eventually succeed");
943
944 assert!(updated);
945 assert_eq!(attempts.load(Ordering::SeqCst), 3);
946 }
947
948 #[tokio::test]
949 async fn retry_mark_sent_without_delays_runs_one_attempt() {
950 let attempts = Arc::new(AtomicUsize::new(0));
951 let attempts_for_closure = attempts.clone();
952
953 let error = retry_mark_sent(42, &[], move |_id, _attempt| {
954 let attempts = attempts_for_closure.clone();
955 async move {
956 attempts.fetch_add(1, Ordering::SeqCst);
957 Err::<bool, _>("db down")
958 }
959 })
960 .await
961 .expect_err("mark_sent should fail");
962
963 assert_eq!(error, "db down");
964 assert_eq!(attempts.load(Ordering::SeqCst), 1);
965 }
966}