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.
61pub async fn insert_email_delivery(
62    conn: &mut PgConnection,
63    user_id: Uuid,
64    email_template_id: Uuid,
65) -> ModelResult<Uuid> {
66    let check = sqlx::query_as!(
67        CheckUserAndTemplateRow,
68        r#"
69SELECT
70    EXISTS(SELECT 1 FROM users WHERE id = $1 AND deleted_at IS NULL) AS "user_ok!",
71    EXISTS(SELECT 1 FROM email_templates WHERE id = $2 AND deleted_at IS NULL) AS "template_ok!"
72        "#,
73        user_id,
74        email_template_id
75    )
76    .fetch_one(&mut *conn)
77    .await?;
78    if !check.user_ok {
79        return Err(ModelError::new(
80            ModelErrorType::PreconditionFailed,
81            "User not found or deleted".to_string(),
82            None,
83        ));
84    }
85    if !check.template_ok {
86        return Err(ModelError::new(
87            ModelErrorType::PreconditionFailed,
88            "Email template not found or deleted".to_string(),
89            None,
90        ));
91    }
92
93    let id = Uuid::new_v4();
94    sqlx::query!(
95        r#"
96INSERT INTO email_deliveries (
97    id,
98    user_id,
99    email_template_id
100)
101VALUES ($1, $2, $3)
102        "#,
103        id,
104        user_id,
105        email_template_id
106    )
107    .execute(conn)
108    .await?;
109
110    Ok(id)
111}
112
113struct CheckUserAndTemplateRow {
114    user_ok: bool,
115    template_ok: bool,
116}
117
118/// Queues an email to a raw address, for recipients who may have no account here.
119pub async fn insert_email_delivery_to_address(
120    conn: &mut PgConnection,
121    recipient_email: &str,
122    email_template_id: Uuid,
123    placeholders: &serde_json::Value,
124) -> ModelResult<Uuid> {
125    let template_ok = sqlx::query_scalar!(
126        r#"
127SELECT EXISTS(SELECT 1 FROM email_templates WHERE id = $1 AND deleted_at IS NULL) AS "template_ok!"
128        "#,
129        email_template_id
130    )
131    .fetch_one(&mut *conn)
132    .await?;
133    if !template_ok {
134        return Err(ModelError::new(
135            ModelErrorType::PreconditionFailed,
136            "Email template not found or deleted".to_string(),
137            None,
138        ));
139    }
140
141    let id = Uuid::new_v4();
142    sqlx::query!(
143        r#"
144INSERT INTO email_deliveries (
145    id,
146    recipient_email,
147    email_template_id,
148    placeholders
149)
150VALUES ($1, $2, $3, $4)
151        "#,
152        id,
153        recipient_email,
154        email_template_id,
155        placeholders
156    )
157    .execute(conn)
158    .await?;
159
160    Ok(id)
161}
162
163pub async fn fetch_emails(conn: &mut PgConnection) -> ModelResult<Vec<Email>> {
164    let emails = sqlx::query_as!(
165        Email,
166        r#"
167WITH due AS (
168    SELECT
169        ed.id
170    FROM email_deliveries ed
171    LEFT JOIN users u ON u.id = ed.user_id
172    LEFT JOIN user_details ud ON ud.user_id = ed.user_id
173    JOIN email_templates et ON et.id = ed.email_template_id
174    WHERE ed.deleted_at IS NULL
175      AND ed.sent = FALSE
176      AND ed.retryable = TRUE
177      AND (ed.user_id IS NULL OR u.deleted_at IS NULL)
178      AND (ed.recipient_email IS NOT NULL OR ud.email IS NOT NULL)
179      AND et.deleted_at IS NULL
180      AND (ed.next_retry_at IS NULL OR ed.next_retry_at <= now())
181    ORDER BY coalesce(ed.next_retry_at, '-infinity'::timestamptz), ed.created_at
182    -- OF ed is required, not cosmetic: rows on the nullable side of an outer join cannot be locked.
183    FOR UPDATE OF ed SKIP LOCKED
184    LIMIT $1
185),
186claimed AS (
187    UPDATE email_deliveries ed
188    SET last_attempt_at = now(),
189        -- Crash-recovery lease for claimed rows; this is not retry backoff.
190        next_retry_at = now() + interval '5 minutes'
191    FROM due
192    WHERE ed.id = due.id
193    RETURNING
194        ed.id,
195        ed.user_id,
196        ed.recipient_email,
197        ed.placeholders,
198        ed.email_template_id,
199        ed.retry_count,
200        ed.next_retry_at,
201        ed.retryable,
202        ed.first_failed_at,
203        ed.last_attempt_at
204)
205SELECT
206    c.id AS id,
207    c.user_id AS user_id,
208    COALESCE(c.recipient_email, ud.email) AS "to!",
209    et.subject AS subject,
210    et.content AS body,
211    et.email_template_type AS "template_type",
212    c.placeholders AS placeholders,
213    c.retry_count AS retry_count,
214    c.next_retry_at AS next_retry_at,
215    c.retryable AS retryable,
216    c.first_failed_at AS first_failed_at,
217    c.last_attempt_at AS last_attempt_at
218FROM claimed c
219JOIN email_templates et ON et.id = c.email_template_id
220LEFT JOIN user_details ud ON ud.user_id = c.user_id
221ORDER BY c.last_attempt_at ASC;
222        "#,
223        FETCH_LIMIT
224    )
225    .fetch_all(conn)
226    .await?;
227
228    Ok(emails)
229}
230
231pub async fn mark_as_sent(conn: &mut PgConnection, email_id: Uuid) -> ModelResult<()> {
232    sqlx::query!(
233        "
234update email_deliveries
235set sent = TRUE,
236    next_retry_at = NULL
237where id = $1;
238    ",
239        email_id
240    )
241    .execute(conn)
242    .await?;
243
244    Ok(())
245}
246
247pub async fn insert_email_delivery_error(
248    conn: &mut PgConnection,
249    error: EmailDeliveryErrorInsert,
250) -> ModelResult<Uuid> {
251    let id = Uuid::new_v4();
252    sqlx::query!(
253        r#"
254INSERT INTO email_delivery_errors (
255    id,
256    email_delivery_id,
257    attempt,
258    error_message,
259    error_code,
260    smtp_response,
261    smtp_response_code,
262    is_transient
263)
264VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
265        "#,
266        id,
267        error.email_delivery_id,
268        error.attempt,
269        error.error_message,
270        error.error_code,
271        error.smtp_response,
272        error.smtp_response_code,
273        error.is_transient
274    )
275    .execute(conn)
276    .await?;
277
278    Ok(id)
279}
280
281pub struct EmailDeliveryErrorInsert {
282    pub email_delivery_id: Uuid,
283    pub attempt: i32,
284    pub error_message: String,
285    pub error_code: Option<String>,
286    pub smtp_response: Option<String>,
287    pub smtp_response_code: Option<i32>,
288    pub is_transient: bool,
289}
290
291#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
292pub struct EmailDeliveryError {
293    pub id: Uuid,
294    pub email_delivery_id: Uuid,
295    pub attempt: i32,
296    pub error_message: String,
297    pub error_code: Option<String>,
298    pub smtp_response: Option<String>,
299    pub smtp_response_code: Option<i32>,
300    pub is_transient: bool,
301    pub created_at: DateTime<Utc>,
302    pub updated_at: DateTime<Utc>,
303    pub deleted_at: Option<DateTime<Utc>>,
304}
305
306pub async fn increment_retry_and_schedule(
307    conn: &mut PgConnection,
308    email_id: Uuid,
309    next_retry_at: Option<DateTime<Utc>>,
310) -> ModelResult<()> {
311    sqlx::query!(
312        "
313UPDATE email_deliveries
314SET retry_count = retry_count + 1,
315    next_retry_at = $2,
316    first_failed_at = COALESCE(first_failed_at, NOW())
317where id = $1;
318    ",
319        email_id,
320        next_retry_at
321    )
322    .execute(conn)
323    .await?;
324
325    Ok(())
326}
327
328pub async fn increment_retry_and_mark_non_retryable(
329    conn: &mut PgConnection,
330    email_id: Uuid,
331) -> ModelResult<()> {
332    sqlx::query!(
333        "
334UPDATE email_deliveries
335SET retry_count = retry_count + 1,
336    first_failed_at = COALESCE(first_failed_at, NOW()),
337    retryable = FALSE,
338    next_retry_at = NULL
339WHERE id = $1;
340    ",
341        email_id
342    )
343    .execute(conn)
344    .await?;
345
346    Ok(())
347}
348
349/// What we can honestly say about an email we queued.
350///
351/// We only hand messages to an SMTP relay, so copy rendering this must never say "delivered" or
352/// "received".
353#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
354#[serde(rename_all = "snake_case")]
355pub enum EmailSendStatus {
356    /// In our queue, not handed over yet.
357    Queued,
358    /// At least one attempt failed transiently; `next_retry_at` says when we try again.
359    Retrying,
360    /// Handed to the mail relay. Not a delivery confirmation.
361    Sent,
362    /// We could not hand it over at all, and will not try again.
363    SendFailed,
364}
365
366/// The shared payload for every surface that reports on a queued email.
367#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
368pub struct EmailSendStatusReport {
369    pub email_send_status: EmailSendStatus,
370    pub sent_at: Option<DateTime<Utc>>,
371    pub last_attempt_at: Option<DateTime<Utc>>,
372    pub retry_count: i32,
373    pub next_retry_at: Option<DateTime<Utc>>,
374    pub failure_code: Option<String>,
375    pub failure_is_transient: Option<bool>,
376}
377
378/// The delivery-row facts [`derive_email_send_status`] needs, so the derivation itself is pure.
379#[derive(Debug, PartialEq, Clone)]
380pub struct EmailSendStatusFacts {
381    pub sent: bool,
382    pub retryable: bool,
383    pub retry_count: i32,
384    pub next_retry_at: Option<DateTime<Utc>>,
385    pub first_failed_at: Option<DateTime<Utc>>,
386    pub last_attempt_at: Option<DateTime<Utc>>,
387    /// From the newest `email_delivery_errors` row, if any.
388    pub failure_code: Option<String>,
389    pub failure_is_transient: Option<bool>,
390}
391
392/// The one derivation of send status. Every surface goes through it.
393pub fn derive_email_send_status(
394    facts: &EmailSendStatusFacts,
395    now: DateTime<Utc>,
396) -> EmailSendStatusReport {
397    let window_expired = facts
398        .first_failed_at
399        .is_some_and(|first| (now - first).num_seconds() > RETRY_WINDOW_SECS);
400
401    let email_send_status = if facts.sent {
402        EmailSendStatus::Sent
403    } else if !facts.retryable || window_expired {
404        EmailSendStatus::SendFailed
405    } else if facts.retry_count > 0 {
406        EmailSendStatus::Retrying
407    } else {
408        EmailSendStatus::Queued
409    };
410
411    EmailSendStatusReport {
412        email_send_status,
413        // There is no sent_at column; on a sent row the last attempt is the one that succeeded.
414        sent_at: if facts.sent {
415            facts.last_attempt_at
416        } else {
417            None
418        },
419        last_attempt_at: facts.last_attempt_at,
420        retry_count: facts.retry_count,
421        next_retry_at: match email_send_status {
422            EmailSendStatus::Retrying => facts.next_retry_at,
423            _ => None,
424        },
425        failure_code: facts.failure_code.clone(),
426        failure_is_transient: facts.failure_is_transient,
427    }
428}
429
430pub async fn get_send_statuses(
431    conn: &mut PgConnection,
432    email_delivery_ids: &[Uuid],
433) -> ModelResult<HashMap<Uuid, EmailSendStatusReport>> {
434    let rows = sqlx::query!(
435        r#"
436SELECT ed.id,
437  ed.sent,
438  ed.retryable,
439  ed.retry_count,
440  ed.next_retry_at,
441  ed.first_failed_at,
442  ed.last_attempt_at,
443  latest_error.error_code,
444  latest_error.is_transient
445FROM email_deliveries ed
446  LEFT JOIN LATERAL (
447    SELECT ede.error_code,
448      ede.is_transient
449    FROM email_delivery_errors ede
450    WHERE ede.email_delivery_id = ed.id
451      AND ede.deleted_at IS NULL
452    ORDER BY ede.attempt DESC,
453      ede.created_at DESC
454    LIMIT 1
455  ) latest_error ON TRUE
456WHERE ed.id = ANY($1::uuid [])
457  AND ed.deleted_at IS NULL
458        "#,
459        email_delivery_ids
460    )
461    .fetch_all(conn)
462    .await?;
463
464    let now = Utc::now();
465    let res = rows
466        .into_iter()
467        .map(|row| {
468            let facts = EmailSendStatusFacts {
469                sent: row.sent,
470                retryable: row.retryable,
471                retry_count: row.retry_count,
472                next_retry_at: row.next_retry_at,
473                first_failed_at: row.first_failed_at,
474                last_attempt_at: row.last_attempt_at,
475                failure_code: row.error_code,
476                failure_is_transient: row.is_transient,
477            };
478            (row.id, derive_email_send_status(&facts, now))
479        })
480        .collect();
481    Ok(res)
482}
483
484pub async fn get_send_status(
485    conn: &mut PgConnection,
486    email_delivery_id: Uuid,
487) -> ModelResult<Option<EmailSendStatusReport>> {
488    let mut statuses = get_send_statuses(conn, &[email_delivery_id]).await?;
489    Ok(statuses.remove(&email_delivery_id))
490}
491
492/// Soft-deletes unsent, still-retryable email deliveries for a user. Call when soft-deleting the user so pending deliveries are not retried.
493pub async fn soft_delete_unsent_retryable_deliveries_for_user(
494    conn: &mut PgConnection,
495    user_id: Uuid,
496) -> ModelResult<()> {
497    sqlx::query!(
498        "
499UPDATE email_deliveries
500SET deleted_at = NOW()
501WHERE user_id = $1
502  AND deleted_at IS NULL
503  AND sent = FALSE
504  AND retryable = TRUE",
505        user_id
506    )
507    .execute(conn)
508    .await?;
509    Ok(())
510}
511
512/// Probabilistic purge instead of a cron, in the style of the token cleanups.
513///
514/// Only raw-address rows ever hold an address; a delivery addressed by `user_id` resolves the address
515/// from `user_details` at send time and stores nothing.
516pub async fn maybe_purge_expired_recipient_addresses(conn: &mut PgConnection) -> ModelResult<u64> {
517    if rand::rng().random_range(1..=PURGE_CHANCE_IN) != 1 {
518        return Ok(0);
519    }
520    info!("Purging retained recipient addresses past their retention window");
521    let result = sqlx::query!(
522        r#"
523UPDATE email_deliveries ed
524SET recipient_email = NULL,
525    placeholders = CASE
526      WHEN ed.placeholders IS NULL THEN NULL
527      ELSE ed.placeholders - 'EMAIL'
528    END,
529    -- Without an address the row can never be delivered, so retire it here instead of leaving the
530    -- sender to claim it and fail. The CHECK constraint also requires this.
531    retryable = CASE WHEN ed.sent THEN ed.retryable ELSE FALSE END,
532    next_retry_at = CASE WHEN ed.sent THEN ed.next_retry_at ELSE NULL END,
533    deleted_at = CASE
534      WHEN ed.sent OR ed.deleted_at IS NOT NULL THEN ed.deleted_at
535      ELSE now()
536    END
537WHERE ed.recipient_email IS NOT NULL
538  AND ed.created_at < now() - $1::text::interval
539  -- The sender stamps last_attempt_at when it claims a row and holds a five minute lease, so an hour
540  -- of quiet means nothing is mid-send.
541  AND (
542    ed.sent
543    OR ed.last_attempt_at IS NULL
544    OR ed.last_attempt_at < now() - interval '1 hour'
545  )
546        "#,
547        RECIPIENT_ADDRESS_RETENTION
548    )
549    .execute(conn)
550    .await?;
551    Ok(result.rows_affected())
552}
553
554#[cfg(test)]
555mod tests {
556    use chrono::Duration;
557
558    use super::*;
559
560    fn queued_facts() -> EmailSendStatusFacts {
561        EmailSendStatusFacts {
562            sent: false,
563            retryable: true,
564            retry_count: 0,
565            next_retry_at: None,
566            first_failed_at: None,
567            last_attempt_at: None,
568            failure_code: None,
569            failure_is_transient: None,
570        }
571    }
572
573    #[test]
574    fn queued_when_nothing_has_been_attempted() {
575        let now = Utc::now();
576        let report = derive_email_send_status(&queued_facts(), now);
577        assert_eq!(report.email_send_status, EmailSendStatus::Queued);
578        assert_eq!(report.sent_at, None);
579        assert_eq!(report.next_retry_at, None);
580    }
581
582    #[test]
583    fn retrying_after_a_transient_failure_reports_when_we_try_again() {
584        let now = Utc::now();
585        let next_retry_at = now + Duration::minutes(5);
586        let facts = EmailSendStatusFacts {
587            retry_count: 1,
588            next_retry_at: Some(next_retry_at),
589            first_failed_at: Some(now - Duration::minutes(1)),
590            last_attempt_at: Some(now - Duration::minutes(1)),
591            failure_code: Some("transient".to_string()),
592            failure_is_transient: Some(true),
593            ..queued_facts()
594        };
595        let report = derive_email_send_status(&facts, now);
596        assert_eq!(report.email_send_status, EmailSendStatus::Retrying);
597        assert_eq!(report.next_retry_at, Some(next_retry_at));
598        assert_eq!(report.failure_code.as_deref(), Some("transient"));
599    }
600
601    #[test]
602    fn sent_reports_the_successful_attempt_as_sent_at() {
603        let now = Utc::now();
604        let handed_over_at = now - Duration::minutes(2);
605        let facts = EmailSendStatusFacts {
606            sent: true,
607            last_attempt_at: Some(handed_over_at),
608            ..queued_facts()
609        };
610        let report = derive_email_send_status(&facts, now);
611        assert_eq!(report.email_send_status, EmailSendStatus::Sent);
612        assert_eq!(report.sent_at, Some(handed_over_at));
613    }
614
615    #[test]
616    fn send_failed_when_the_delivery_is_no_longer_retryable() {
617        let now = Utc::now();
618        let facts = EmailSendStatusFacts {
619            retryable: false,
620            retry_count: 1,
621            first_failed_at: Some(now - Duration::minutes(1)),
622            last_attempt_at: Some(now - Duration::minutes(1)),
623            failure_code: Some("permanent".to_string()),
624            failure_is_transient: Some(false),
625            ..queued_facts()
626        };
627        let report = derive_email_send_status(&facts, now);
628        assert_eq!(report.email_send_status, EmailSendStatus::SendFailed);
629        assert_eq!(report.next_retry_at, None);
630        assert_eq!(report.failure_is_transient, Some(false));
631    }
632
633    #[test]
634    fn send_failed_when_the_retry_window_has_expired_even_though_the_row_still_says_retryable() {
635        let now = Utc::now();
636        let facts = EmailSendStatusFacts {
637            retry_count: 9,
638            next_retry_at: Some(now + Duration::hours(1)),
639            first_failed_at: Some(now - Duration::seconds(RETRY_WINDOW_SECS + 1)),
640            last_attempt_at: Some(now - Duration::hours(1)),
641            failure_code: Some("transient".to_string()),
642            failure_is_transient: Some(true),
643            ..queued_facts()
644        };
645        let report = derive_email_send_status(&facts, now);
646        assert_eq!(report.email_send_status, EmailSendStatus::SendFailed);
647        assert_eq!(report.next_retry_at, None);
648    }
649
650    #[test]
651    fn a_sent_delivery_stays_sent_even_if_earlier_attempts_failed() {
652        let now = Utc::now();
653        let facts = EmailSendStatusFacts {
654            sent: true,
655            retryable: false,
656            retry_count: 2,
657            first_failed_at: Some(now - Duration::seconds(RETRY_WINDOW_SECS + 1)),
658            last_attempt_at: Some(now),
659            failure_code: Some("transient".to_string()),
660            failure_is_transient: Some(true),
661            ..queued_facts()
662        };
663        assert_eq!(
664            derive_email_send_status(&facts, now).email_send_status,
665            EmailSendStatus::Sent
666        );
667    }
668}