1use std::collections::HashMap;
2
3use rand::RngExt;
4use utoipa::ToSchema;
5
6use crate::email_templates::EmailTemplateType;
7use crate::prelude::*;
8
9pub const FETCH_LIMIT: i64 = 20;
10
11pub const RETRY_WINDOW_SECS: i64 = 3 * 24 * 60 * 60;
15
16const RECIPIENT_ADDRESS_RETENTION: &str = "1 month";
18
19const PURGE_CHANCE_IN: u32 = 10;
21
22#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
23pub struct EmailDelivery {
24 pub id: Uuid,
25 pub created_at: DateTime<Utc>,
26 pub updated_at: DateTime<Utc>,
27 pub deleted_at: Option<DateTime<Utc>>,
28 pub email_template_id: Uuid,
29 pub sent: bool,
30 pub user_id: Option<Uuid>,
32 pub recipient_email: Option<String>,
33 pub placeholders: Option<serde_json::Value>,
34 pub retry_count: i32,
36 pub next_retry_at: Option<DateTime<Utc>>,
37 pub retryable: bool,
38 pub first_failed_at: Option<DateTime<Utc>>,
39 pub last_attempt_at: Option<DateTime<Utc>>,
40}
41
42pub struct Email {
43 pub id: Uuid,
44 pub user_id: Option<Uuid>,
46 pub to: String,
47 pub subject: Option<String>,
48 pub body: Option<serde_json::Value>,
49 pub template_type: Option<EmailTemplateType>,
50 pub placeholders: Option<serde_json::Value>,
52 pub retry_count: i32,
54 pub next_retry_at: Option<DateTime<Utc>>,
55 pub retryable: bool,
56 pub first_failed_at: Option<DateTime<Utc>>,
57 pub last_attempt_at: Option<DateTime<Utc>>,
58}
59
60pub async fn insert_email_delivery(
65 conn: &mut PgConnection,
66 user_id: Uuid,
67 email_template_id: Uuid,
68) -> ModelResult<Uuid> {
69 insert_delivery_for_user(conn, user_id, email_template_id, None).await
70}
71
72pub async fn insert_email_delivery_with_placeholders(
77 conn: &mut PgConnection,
78 user_id: Uuid,
79 email_template_id: Uuid,
80 placeholders: &serde_json::Value,
81) -> ModelResult<Uuid> {
82 insert_delivery_for_user(conn, user_id, email_template_id, Some(placeholders)).await
83}
84
85async fn insert_delivery_for_user(
86 conn: &mut PgConnection,
87 user_id: Uuid,
88 email_template_id: Uuid,
89 placeholders: Option<&serde_json::Value>,
90) -> ModelResult<Uuid> {
91 let check = sqlx::query_as!(
92 CheckUserAndTemplateRow,
93 r#"
94SELECT
95 EXISTS(SELECT 1 FROM users WHERE id = $1 AND deleted_at IS NULL) AS "user_ok!",
96 EXISTS(SELECT 1 FROM email_templates WHERE id = $2 AND deleted_at IS NULL) AS "template_ok!"
97 "#,
98 user_id,
99 email_template_id
100 )
101 .fetch_one(&mut *conn)
102 .await?;
103 if !check.user_ok {
104 return Err(ModelError::new(
105 ModelErrorType::PreconditionFailed,
106 "User not found or deleted".to_string(),
107 None,
108 ));
109 }
110 if !check.template_ok {
111 return Err(ModelError::new(
112 ModelErrorType::PreconditionFailed,
113 "Email template not found or deleted".to_string(),
114 None,
115 ));
116 }
117
118 let id = Uuid::new_v4();
119 sqlx::query!(
120 r#"
121INSERT INTO email_deliveries (
122 id,
123 user_id,
124 email_template_id,
125 placeholders
126)
127VALUES ($1, $2, $3, $4)
128 "#,
129 id,
130 user_id,
131 email_template_id,
132 placeholders,
133 )
134 .execute(conn)
135 .await?;
136
137 Ok(id)
138}
139
140struct CheckUserAndTemplateRow {
141 user_ok: bool,
142 template_ok: bool,
143}
144
145pub async fn insert_email_delivery_to_address(
147 conn: &mut PgConnection,
148 recipient_email: &str,
149 email_template_id: Uuid,
150 placeholders: &serde_json::Value,
151) -> ModelResult<Uuid> {
152 let template_ok = sqlx::query_scalar!(
153 r#"
154SELECT EXISTS(SELECT 1 FROM email_templates WHERE id = $1 AND deleted_at IS NULL) AS "template_ok!"
155 "#,
156 email_template_id
157 )
158 .fetch_one(&mut *conn)
159 .await?;
160 if !template_ok {
161 return Err(ModelError::new(
162 ModelErrorType::PreconditionFailed,
163 "Email template not found or deleted".to_string(),
164 None,
165 ));
166 }
167
168 let id = Uuid::new_v4();
169 sqlx::query!(
170 r#"
171INSERT INTO email_deliveries (
172 id,
173 recipient_email,
174 email_template_id,
175 placeholders
176)
177VALUES ($1, $2, $3, $4)
178 "#,
179 id,
180 recipient_email,
181 email_template_id,
182 placeholders
183 )
184 .execute(conn)
185 .await?;
186
187 Ok(id)
188}
189
190pub async fn fetch_emails(conn: &mut PgConnection) -> ModelResult<Vec<Email>> {
191 let emails = sqlx::query_as!(
192 Email,
193 r#"
194WITH due AS (
195 SELECT
196 ed.id
197 FROM email_deliveries ed
198 LEFT JOIN users u ON u.id = ed.user_id
199 LEFT JOIN user_details ud ON ud.user_id = ed.user_id
200 JOIN email_templates et ON et.id = ed.email_template_id
201 WHERE ed.deleted_at IS NULL
202 AND ed.sent = FALSE
203 AND ed.retryable = TRUE
204 AND (ed.user_id IS NULL OR u.deleted_at IS NULL)
205 AND (ed.recipient_email IS NOT NULL OR ud.email IS NOT NULL)
206 AND et.deleted_at IS NULL
207 AND (ed.next_retry_at IS NULL OR ed.next_retry_at <= now())
208 ORDER BY coalesce(ed.next_retry_at, '-infinity'::timestamptz), ed.created_at
209 -- OF ed is required, not cosmetic: rows on the nullable side of an outer join cannot be locked.
210 FOR UPDATE OF ed SKIP LOCKED
211 LIMIT $1
212),
213claimed AS (
214 UPDATE email_deliveries ed
215 SET last_attempt_at = now(),
216 -- Crash-recovery lease for claimed rows; this is not retry backoff.
217 next_retry_at = now() + interval '5 minutes'
218 FROM due
219 WHERE ed.id = due.id
220 RETURNING
221 ed.id,
222 ed.user_id,
223 ed.recipient_email,
224 ed.placeholders,
225 ed.email_template_id,
226 ed.retry_count,
227 ed.next_retry_at,
228 ed.retryable,
229 ed.first_failed_at,
230 ed.last_attempt_at
231)
232SELECT
233 c.id AS id,
234 c.user_id AS user_id,
235 COALESCE(c.recipient_email, ud.email) AS "to!",
236 et.subject AS subject,
237 et.content AS body,
238 et.email_template_type AS "template_type",
239 c.placeholders AS placeholders,
240 c.retry_count AS retry_count,
241 c.next_retry_at AS next_retry_at,
242 c.retryable AS retryable,
243 c.first_failed_at AS first_failed_at,
244 c.last_attempt_at AS last_attempt_at
245FROM claimed c
246JOIN email_templates et ON et.id = c.email_template_id
247LEFT JOIN user_details ud ON ud.user_id = c.user_id
248ORDER BY c.last_attempt_at ASC;
249 "#,
250 FETCH_LIMIT
251 )
252 .fetch_all(conn)
253 .await?;
254
255 Ok(emails)
256}
257
258pub async fn mark_as_sent(conn: &mut PgConnection, email_id: Uuid) -> ModelResult<()> {
259 sqlx::query!(
260 "
261update email_deliveries
262set sent = TRUE,
263 next_retry_at = NULL
264where id = $1;
265 ",
266 email_id
267 )
268 .execute(conn)
269 .await?;
270
271 Ok(())
272}
273
274pub async fn insert_email_delivery_error(
275 conn: &mut PgConnection,
276 error: EmailDeliveryErrorInsert,
277) -> ModelResult<Uuid> {
278 let id = Uuid::new_v4();
279 sqlx::query!(
280 r#"
281INSERT INTO email_delivery_errors (
282 id,
283 email_delivery_id,
284 attempt,
285 error_message,
286 error_code,
287 smtp_response,
288 smtp_response_code,
289 is_transient
290)
291VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
292 "#,
293 id,
294 error.email_delivery_id,
295 error.attempt,
296 error.error_message,
297 error.error_code,
298 error.smtp_response,
299 error.smtp_response_code,
300 error.is_transient
301 )
302 .execute(conn)
303 .await?;
304
305 Ok(id)
306}
307
308pub struct EmailDeliveryErrorInsert {
309 pub email_delivery_id: Uuid,
310 pub attempt: i32,
311 pub error_message: String,
312 pub error_code: Option<String>,
313 pub smtp_response: Option<String>,
314 pub smtp_response_code: Option<i32>,
315 pub is_transient: bool,
316}
317
318#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
319pub struct EmailDeliveryError {
320 pub id: Uuid,
321 pub email_delivery_id: Uuid,
322 pub attempt: i32,
323 pub error_message: String,
324 pub error_code: Option<String>,
325 pub smtp_response: Option<String>,
326 pub smtp_response_code: Option<i32>,
327 pub is_transient: bool,
328 pub created_at: DateTime<Utc>,
329 pub updated_at: DateTime<Utc>,
330 pub deleted_at: Option<DateTime<Utc>>,
331}
332
333pub async fn get_recent_template_types_for_user_for_testing(
336 conn: &mut PgConnection,
337 user_id: Uuid,
338 limit: i64,
339) -> ModelResult<Vec<(EmailTemplateType, serde_json::Value)>> {
340 let rows = sqlx::query!(
341 r#"
342SELECT t.email_template_type AS "template_type",
343 COALESCE(d.placeholders, '{}'::jsonb) AS "placeholders!"
344FROM email_deliveries d
345 JOIN email_templates t ON t.id = d.email_template_id
346WHERE d.user_id = $1
347 AND d.deleted_at IS NULL
348ORDER BY d.created_at DESC
349LIMIT $2
350 "#,
351 user_id,
352 limit,
353 )
354 .fetch_all(conn)
355 .await?;
356 Ok(rows
357 .into_iter()
358 .map(|row| (row.template_type, row.placeholders))
359 .collect())
360}
361
362pub async fn increment_retry_and_schedule(
363 conn: &mut PgConnection,
364 email_id: Uuid,
365 next_retry_at: Option<DateTime<Utc>>,
366) -> ModelResult<()> {
367 sqlx::query!(
368 "
369UPDATE email_deliveries
370SET retry_count = retry_count + 1,
371 next_retry_at = $2,
372 first_failed_at = COALESCE(first_failed_at, NOW())
373where id = $1;
374 ",
375 email_id,
376 next_retry_at
377 )
378 .execute(conn)
379 .await?;
380
381 Ok(())
382}
383
384pub async fn increment_retry_and_mark_non_retryable(
385 conn: &mut PgConnection,
386 email_id: Uuid,
387) -> ModelResult<()> {
388 sqlx::query!(
389 "
390UPDATE email_deliveries
391SET retry_count = retry_count + 1,
392 first_failed_at = COALESCE(first_failed_at, NOW()),
393 retryable = FALSE,
394 next_retry_at = NULL
395WHERE id = $1;
396 ",
397 email_id
398 )
399 .execute(conn)
400 .await?;
401
402 Ok(())
403}
404
405#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
410#[serde(rename_all = "snake_case")]
411pub enum EmailSendStatus {
412 Queued,
414 Retrying,
416 Sent,
418 SendFailed,
420}
421
422#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
424pub struct EmailSendStatusReport {
425 pub email_send_status: EmailSendStatus,
426 pub sent_at: Option<DateTime<Utc>>,
427 pub last_attempt_at: Option<DateTime<Utc>>,
428 pub retry_count: i32,
429 pub next_retry_at: Option<DateTime<Utc>>,
430 pub failure_code: Option<String>,
431 pub failure_is_transient: Option<bool>,
432}
433
434#[derive(Debug, PartialEq, Clone)]
436pub struct EmailSendStatusFacts {
437 pub sent: bool,
438 pub retryable: bool,
439 pub retry_count: i32,
440 pub next_retry_at: Option<DateTime<Utc>>,
441 pub first_failed_at: Option<DateTime<Utc>>,
442 pub last_attempt_at: Option<DateTime<Utc>>,
443 pub failure_code: Option<String>,
445 pub failure_is_transient: Option<bool>,
446}
447
448pub fn is_hard_send_failure(
452 retryable: bool,
453 first_failed_at: Option<DateTime<Utc>>,
454 now: DateTime<Utc>,
455) -> bool {
456 !retryable
457 || first_failed_at.is_some_and(|first| (now - first).num_seconds() > RETRY_WINDOW_SECS)
458}
459
460pub fn derive_email_send_status(
462 facts: &EmailSendStatusFacts,
463 now: DateTime<Utc>,
464) -> EmailSendStatusReport {
465 let email_send_status = if facts.sent {
466 EmailSendStatus::Sent
467 } else if is_hard_send_failure(facts.retryable, facts.first_failed_at, now) {
468 EmailSendStatus::SendFailed
469 } else if facts.retry_count > 0 {
470 EmailSendStatus::Retrying
471 } else {
472 EmailSendStatus::Queued
473 };
474
475 EmailSendStatusReport {
476 email_send_status,
477 sent_at: if facts.sent {
479 facts.last_attempt_at
480 } else {
481 None
482 },
483 last_attempt_at: facts.last_attempt_at,
484 retry_count: facts.retry_count,
485 next_retry_at: match email_send_status {
486 EmailSendStatus::Retrying => facts.next_retry_at,
487 _ => None,
488 },
489 failure_code: facts.failure_code.clone(),
490 failure_is_transient: facts.failure_is_transient,
491 }
492}
493
494pub async fn get_send_statuses(
495 conn: &mut PgConnection,
496 email_delivery_ids: &[Uuid],
497) -> ModelResult<HashMap<Uuid, EmailSendStatusReport>> {
498 let rows = sqlx::query!(
499 r#"
500SELECT ed.id,
501 ed.sent,
502 ed.retryable,
503 ed.retry_count,
504 ed.next_retry_at,
505 ed.first_failed_at,
506 ed.last_attempt_at,
507 latest_error.error_code,
508 latest_error.is_transient
509FROM email_deliveries ed
510 LEFT JOIN LATERAL (
511 SELECT ede.error_code,
512 ede.is_transient
513 FROM email_delivery_errors ede
514 WHERE ede.email_delivery_id = ed.id
515 AND ede.deleted_at IS NULL
516 ORDER BY ede.attempt DESC,
517 ede.created_at DESC
518 LIMIT 1
519 ) latest_error ON TRUE
520WHERE ed.id = ANY($1::uuid [])
521 AND ed.deleted_at IS NULL
522 "#,
523 email_delivery_ids
524 )
525 .fetch_all(conn)
526 .await?;
527
528 let now = Utc::now();
529 let res = rows
530 .into_iter()
531 .map(|row| {
532 let facts = EmailSendStatusFacts {
533 sent: row.sent,
534 retryable: row.retryable,
535 retry_count: row.retry_count,
536 next_retry_at: row.next_retry_at,
537 first_failed_at: row.first_failed_at,
538 last_attempt_at: row.last_attempt_at,
539 failure_code: row.error_code,
540 failure_is_transient: row.is_transient,
541 };
542 (row.id, derive_email_send_status(&facts, now))
543 })
544 .collect();
545 Ok(res)
546}
547
548pub async fn get_send_status(
549 conn: &mut PgConnection,
550 email_delivery_id: Uuid,
551) -> ModelResult<Option<EmailSendStatusReport>> {
552 let mut statuses = get_send_statuses(conn, &[email_delivery_id]).await?;
553 Ok(statuses.remove(&email_delivery_id))
554}
555
556#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
562pub struct UserEmailDeliverySummary {
563 pub email_template_type: EmailTemplateType,
564 pub created_at: DateTime<Utc>,
565 pub status: EmailSendStatus,
566 pub retry_count: i32,
567 pub last_attempt_at: Option<DateTime<Utc>>,
568 pub failure_code: Option<String>,
569 pub failure_is_transient: Option<bool>,
570}
571
572pub async fn get_recent_deliveries_for_user(
574 conn: &mut PgConnection,
575 user_id: Uuid,
576 limit: i64,
577) -> ModelResult<Vec<UserEmailDeliverySummary>> {
578 let rows = sqlx::query!(
579 r#"
580SELECT et.email_template_type,
581 ed.created_at,
582 ed.sent,
583 ed.retryable,
584 ed.retry_count,
585 ed.next_retry_at,
586 ed.first_failed_at,
587 ed.last_attempt_at,
588 latest_error.error_code,
589 latest_error.is_transient AS "is_transient?"
590FROM email_deliveries ed
591 JOIN email_templates et ON et.id = ed.email_template_id
592 LEFT JOIN LATERAL (
593 SELECT ede.error_code,
594 ede.is_transient
595 FROM email_delivery_errors ede
596 WHERE ede.email_delivery_id = ed.id
597 AND ede.deleted_at IS NULL
598 ORDER BY ede.attempt DESC,
599 ede.created_at DESC
600 LIMIT 1
601 ) latest_error ON TRUE
602WHERE ed.user_id = $1
603 AND ed.deleted_at IS NULL
604ORDER BY ed.created_at DESC
605LIMIT $2
606 "#,
607 user_id,
608 limit
609 )
610 .fetch_all(conn)
611 .await?;
612
613 let now = Utc::now();
614 let summaries = rows
615 .into_iter()
616 .map(|row| {
617 let facts = EmailSendStatusFacts {
618 sent: row.sent,
619 retryable: row.retryable,
620 retry_count: row.retry_count,
621 next_retry_at: row.next_retry_at,
622 first_failed_at: row.first_failed_at,
623 last_attempt_at: row.last_attempt_at,
624 failure_code: row.error_code,
625 failure_is_transient: row.is_transient,
626 };
627 let report = derive_email_send_status(&facts, now);
628 UserEmailDeliverySummary {
629 email_template_type: row.email_template_type,
630 created_at: row.created_at,
631 status: report.email_send_status,
632 retry_count: report.retry_count,
633 last_attempt_at: report.last_attempt_at,
634 failure_code: report.failure_code,
635 failure_is_transient: report.failure_is_transient,
636 }
637 })
638 .collect();
639 Ok(summaries)
640}
641
642pub async fn soft_delete_unsent_retryable_deliveries_for_user(
644 conn: &mut PgConnection,
645 user_id: Uuid,
646) -> ModelResult<()> {
647 sqlx::query!(
648 "
649UPDATE email_deliveries
650SET deleted_at = NOW()
651WHERE user_id = $1
652 AND deleted_at IS NULL
653 AND sent = FALSE
654 AND retryable = TRUE",
655 user_id
656 )
657 .execute(conn)
658 .await?;
659 Ok(())
660}
661
662pub async fn maybe_purge_expired_recipient_addresses(conn: &mut PgConnection) -> ModelResult<u64> {
667 if rand::rng().random_range(1..=PURGE_CHANCE_IN) != 1 {
668 return Ok(0);
669 }
670 info!("Purging retained recipient addresses past their retention window");
671 let result = sqlx::query!(
672 r#"
673UPDATE email_deliveries ed
674SET recipient_email = NULL,
675 placeholders = CASE
676 WHEN ed.placeholders IS NULL THEN NULL
677 ELSE ed.placeholders - 'EMAIL'
678 END,
679 -- Without an address the row can never be delivered, so retire it here instead of leaving the
680 -- sender to claim it and fail. The CHECK constraint also requires this.
681 retryable = CASE WHEN ed.sent THEN ed.retryable ELSE FALSE END,
682 next_retry_at = CASE WHEN ed.sent THEN ed.next_retry_at ELSE NULL END,
683 deleted_at = CASE
684 WHEN ed.sent OR ed.deleted_at IS NOT NULL THEN ed.deleted_at
685 ELSE now()
686 END
687WHERE ed.recipient_email IS NOT NULL
688 AND ed.created_at < now() - $1::text::interval
689 -- The sender stamps last_attempt_at when it claims a row and holds a five minute lease, so an hour
690 -- of quiet means nothing is mid-send.
691 AND (
692 ed.sent
693 OR ed.last_attempt_at IS NULL
694 OR ed.last_attempt_at < now() - interval '1 hour'
695 )
696 "#,
697 RECIPIENT_ADDRESS_RETENTION
698 )
699 .execute(conn)
700 .await?;
701 Ok(result.rows_affected())
702}
703
704#[cfg(test)]
705mod tests {
706 use chrono::Duration;
707
708 use super::*;
709
710 fn queued_facts() -> EmailSendStatusFacts {
711 EmailSendStatusFacts {
712 sent: false,
713 retryable: true,
714 retry_count: 0,
715 next_retry_at: None,
716 first_failed_at: None,
717 last_attempt_at: None,
718 failure_code: None,
719 failure_is_transient: None,
720 }
721 }
722
723 #[test]
724 fn queued_when_nothing_has_been_attempted() {
725 let now = Utc::now();
726 let report = derive_email_send_status(&queued_facts(), now);
727 assert_eq!(report.email_send_status, EmailSendStatus::Queued);
728 assert_eq!(report.sent_at, None);
729 assert_eq!(report.next_retry_at, None);
730 }
731
732 #[test]
733 fn retrying_after_a_transient_failure_reports_when_we_try_again() {
734 let now = Utc::now();
735 let next_retry_at = now + Duration::minutes(5);
736 let facts = EmailSendStatusFacts {
737 retry_count: 1,
738 next_retry_at: Some(next_retry_at),
739 first_failed_at: Some(now - Duration::minutes(1)),
740 last_attempt_at: Some(now - Duration::minutes(1)),
741 failure_code: Some("transient".to_string()),
742 failure_is_transient: Some(true),
743 ..queued_facts()
744 };
745 let report = derive_email_send_status(&facts, now);
746 assert_eq!(report.email_send_status, EmailSendStatus::Retrying);
747 assert_eq!(report.next_retry_at, Some(next_retry_at));
748 assert_eq!(report.failure_code.as_deref(), Some("transient"));
749 }
750
751 #[test]
752 fn sent_reports_the_successful_attempt_as_sent_at() {
753 let now = Utc::now();
754 let handed_over_at = now - Duration::minutes(2);
755 let facts = EmailSendStatusFacts {
756 sent: true,
757 last_attempt_at: Some(handed_over_at),
758 ..queued_facts()
759 };
760 let report = derive_email_send_status(&facts, now);
761 assert_eq!(report.email_send_status, EmailSendStatus::Sent);
762 assert_eq!(report.sent_at, Some(handed_over_at));
763 }
764
765 #[test]
766 fn send_failed_when_the_delivery_is_no_longer_retryable() {
767 let now = Utc::now();
768 let facts = EmailSendStatusFacts {
769 retryable: false,
770 retry_count: 1,
771 first_failed_at: Some(now - Duration::minutes(1)),
772 last_attempt_at: Some(now - Duration::minutes(1)),
773 failure_code: Some("permanent".to_string()),
774 failure_is_transient: Some(false),
775 ..queued_facts()
776 };
777 let report = derive_email_send_status(&facts, now);
778 assert_eq!(report.email_send_status, EmailSendStatus::SendFailed);
779 assert_eq!(report.next_retry_at, None);
780 assert_eq!(report.failure_is_transient, Some(false));
781 }
782
783 #[test]
784 fn send_failed_when_the_retry_window_has_expired_even_though_the_row_still_says_retryable() {
785 let now = Utc::now();
786 let facts = EmailSendStatusFacts {
787 retry_count: 9,
788 next_retry_at: Some(now + Duration::hours(1)),
789 first_failed_at: Some(now - Duration::seconds(RETRY_WINDOW_SECS + 1)),
790 last_attempt_at: Some(now - Duration::hours(1)),
791 failure_code: Some("transient".to_string()),
792 failure_is_transient: Some(true),
793 ..queued_facts()
794 };
795 let report = derive_email_send_status(&facts, now);
796 assert_eq!(report.email_send_status, EmailSendStatus::SendFailed);
797 assert_eq!(report.next_retry_at, None);
798 }
799
800 #[test]
801 fn a_sent_delivery_stays_sent_even_if_earlier_attempts_failed() {
802 let now = Utc::now();
803 let facts = EmailSendStatusFacts {
804 sent: true,
805 retryable: false,
806 retry_count: 2,
807 first_failed_at: Some(now - Duration::seconds(RETRY_WINDOW_SECS + 1)),
808 last_attempt_at: Some(now),
809 failure_code: Some("transient".to_string()),
810 failure_is_transient: Some(true),
811 ..queued_facts()
812 };
813 assert_eq!(
814 derive_email_send_status(&facts, now).email_send_status,
815 EmailSendStatus::Sent
816 );
817 }
818}