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(
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
118pub 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#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
354#[serde(rename_all = "snake_case")]
355pub enum EmailSendStatus {
356 Queued,
358 Retrying,
360 Sent,
362 SendFailed,
364}
365
366#[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#[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 pub failure_code: Option<String>,
389 pub failure_is_transient: Option<bool>,
390}
391
392pub 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 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
492pub 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
512pub 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}