Skip to main content

headless_lms_models/
email_deliveries.rs

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
11/// How long a delivery keeps being retried after its first failure.
12///
13/// Shared by the sender and [`derive_email_send_status`], which must agree on what has failed.
14pub const RETRY_WINDOW_SECS: i64 = 3 * 24 * 60 * 60;
15
16/// How long a delivery to a raw address may keep that address after being queued.
17const RECIPIENT_ADDRESS_RETENTION: &str = "1 month";
18
19/// One purge in this many calls, matching the token cleanups. The caller schedules how often it asks.
20const 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    /// `None` for deliveries addressed to a raw address; see `recipient_email`.
31    pub user_id: Option<Uuid>,
32    pub recipient_email: Option<String>,
33    pub placeholders: Option<serde_json::Value>,
34    /// Number of failed send attempts recorded so far.
35    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    /// `None` when the mail goes to a raw address with no account here; substitutions must cope.
45    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    /// Substitutions carried on the delivery row, so a mail to a raw address needs no user lookup.
51    pub placeholders: Option<serde_json::Value>,
52    /// Number of failed send attempts recorded so far.
53    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
60/// Inserts an email delivery; fails if the user or email template is soft-deleted.
61///
62/// For a template whose substitutions the sender cannot look up from the account, use
63/// [`insert_email_delivery_with_placeholders`].
64pub 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
72/// Queues a mail to an account, carrying its substitutions on the delivery row.
73///
74/// The sibling of [`insert_email_delivery_to_address`] for recipients we do have an account for:
75/// the address comes from the account, the values in the message do not.
76pub 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
145/// Queues an email to a raw address, for recipients who may have no account here.
146pub 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
333/// Which mails one account has been queued, newest first. There is no mail capture in this repo, so
334/// a system test asserting a message was composed reads the queue instead of an inbox.
335pub 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/// What we can honestly say about an email we queued.
406///
407/// We only hand messages to an SMTP relay, so copy rendering this must never say "delivered" or
408/// "received".
409#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
410#[serde(rename_all = "snake_case")]
411pub enum EmailSendStatus {
412    /// In our queue, not handed over yet.
413    Queued,
414    /// At least one attempt failed transiently; `next_retry_at` says when we try again.
415    Retrying,
416    /// Handed to the mail relay. Not a delivery confirmation.
417    Sent,
418    /// We could not hand it over at all, and will not try again.
419    SendFailed,
420}
421
422/// The shared payload for every surface that reports on a queued email.
423#[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/// The delivery-row facts [`derive_email_send_status`] needs, so the derivation itself is pure.
435#[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    /// From the newest `email_delivery_errors` row, if any.
444    pub failure_code: Option<String>,
445    pub failure_is_transient: Option<bool>,
446}
447
448/// True once a delivery attempt has failed for good: not retryable at all, or retryable but its
449/// retry window has expired. Shared with `credit_registration_account_linking_emails.rs` so the
450/// two cannot drift on what counts as a hard failure.
451pub 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
460/// The one derivation of send status. Every surface goes through it.
461pub 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        // There is no sent_at column; on a sent row the last attempt is the one that succeeded.
478        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/// One delivery attempt, as summarized for a support-facing view of a user's mail history.
557///
558/// Deliberately excludes the message content and the recipient address (which may be purged by
559/// [`maybe_purge_expired_recipient_addresses`] anyway): this exists so support tooling can see
560/// whether mail reached a user, not what was in it.
561#[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
572/// A user's most recent email deliveries, newest first, for support tooling.
573pub 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
642/// Soft-deletes unsent, still-retryable email deliveries for a user. Call when soft-deleting the user so pending deliveries are not retried.
643pub 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
662/// Probabilistic purge instead of a cron, in the style of the token cleanups.
663///
664/// Only raw-address rows ever hold an address; a delivery addressed by `user_id` resolves the address
665/// from `user_details` at send time and stores nothing.
666pub 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}