Skip to main content

headless_lms_models/
credit_registration_account_linking_emails.rs

1//! Dedup ledger for account-linking mails.
2//!
3//! Keyed on the Sisu person id plus the recipient address: at send time there is no account of ours
4//! to key on, and the student number changes when a student moves between programmes. A row is
5//! written when the right to mail is claimed, before a delivery exists, so a crash between the two
6//! phases cannot mail twice.
7use std::collections::{HashMap, HashSet};
8
9use utoipa::ToSchema;
10
11use crate::email_deliveries::{
12    EmailSendStatus, EmailSendStatusFacts, EmailSendStatusReport, derive_email_send_status,
13    get_send_statuses, is_hard_send_failure,
14};
15use crate::prelude::*;
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
18pub struct CreditRegistrationAccountLinkingEmail {
19    pub id: Uuid,
20    pub created_at: DateTime<Utc>,
21    pub updated_at: DateTime<Utc>,
22    pub deleted_at: Option<DateTime<Utc>>,
23    pub student_number: String,
24    pub sisu_person_id: String,
25    pub course_id: Uuid,
26    pub emailed_to: String,
27    pub student_number_verification_token_id: Option<Uuid>,
28    pub email_delivery_id: Option<Uuid>,
29    pub sent_at: DateTime<Utc>,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct NewAccountLinkingEmail {
34    pub student_number: String,
35    pub sisu_person_id: String,
36    pub course_id: Uuid,
37    pub emailed_to: String,
38    pub student_number_verification_token_id: Option<Uuid>,
39    pub email_delivery_id: Option<Uuid>,
40}
41
42/// Claims the right to mail this (person, course, address) once; `None` means the caller must not
43/// send. Call in the transaction that mints the token, so a refused claim leaves no usable link.
44pub async fn claim_send_slot(
45    conn: &mut PgConnection,
46    new: &NewAccountLinkingEmail,
47) -> ModelResult<Option<Uuid>> {
48    let res = sqlx::query!(
49        r#"
50INSERT INTO credit_registration_account_linking_emails (
51    student_number,
52    sisu_person_id,
53    course_id,
54    emailed_to,
55    student_number_verification_token_id,
56    email_delivery_id
57  )
58VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING
59RETURNING id
60        "#,
61        new.student_number,
62        new.sisu_person_id,
63        new.course_id,
64        new.emailed_to,
65        new.student_number_verification_token_id,
66        new.email_delivery_id,
67    )
68    .fetch_optional(conn)
69    .await?;
70    Ok(res.map(|r| r.id))
71}
72
73/// As much of one existing mail as the dedup guard and the rate caps need to decide on a person.
74#[derive(Debug, Clone, PartialEq)]
75pub struct ExistingLinkingMailFact {
76    pub sisu_person_id: String,
77    pub course_id: Uuid,
78    pub emailed_to: String,
79    pub sent_at: DateTime<Utc>,
80}
81
82/// Every live mail these people have ever been sent, any course: one query stands in for the
83/// quiet-period count, the per-course count and the dedup check of a whole batch of candidates.
84pub async fn get_existing_facts_for_persons(
85    conn: &mut PgConnection,
86    sisu_person_ids: &[String],
87) -> ModelResult<Vec<ExistingLinkingMailFact>> {
88    let res = sqlx::query_as!(
89        ExistingLinkingMailFact,
90        r#"
91SELECT sisu_person_id,
92  course_id,
93  emailed_to,
94  sent_at
95FROM credit_registration_account_linking_emails
96WHERE sisu_person_id = ANY($1::text [])
97  AND deleted_at IS NULL
98        "#,
99        sisu_person_ids
100    )
101    .fetch_all(conn)
102    .await?;
103    Ok(res)
104}
105
106/// Batched form of [`claim_send_slot`], keyed by token: returns the
107/// `student_number_verification_token_id` of every row actually inserted, so the caller can tell
108/// which candidates lost the race to the unique index.
109///
110/// `token_ids[i]` is the token already minted for `new[i]`, taken separately rather than read from
111/// `new[i].student_number_verification_token_id`: that field stays `Option` because it really can be
112/// absent on the single-row [`claim_send_slot`], but a batched claim always has one.
113pub async fn claim_send_slots(
114    conn: &mut PgConnection,
115    new: &[NewAccountLinkingEmail],
116    token_ids: &[Uuid],
117) -> ModelResult<HashSet<Uuid>> {
118    if new.is_empty() {
119        return Ok(HashSet::new());
120    }
121    let student_numbers: Vec<String> = new.iter().map(|n| n.student_number.clone()).collect();
122    let sisu_person_ids: Vec<String> = new.iter().map(|n| n.sisu_person_id.clone()).collect();
123    let course_ids: Vec<Uuid> = new.iter().map(|n| n.course_id).collect();
124    let emailed_tos: Vec<String> = new.iter().map(|n| n.emailed_to.clone()).collect();
125
126    let claimed = sqlx::query_scalar!(
127        r#"
128INSERT INTO credit_registration_account_linking_emails (
129    student_number,
130    sisu_person_id,
131    course_id,
132    emailed_to,
133    student_number_verification_token_id
134  )
135SELECT * FROM UNNEST($1::text [], $2::text [], $3::uuid [], $4::text [], $5::uuid []) ON CONFLICT DO NOTHING
136RETURNING student_number_verification_token_id AS "token_id!"
137        "#,
138        &student_numbers,
139        &sisu_person_ids,
140        &course_ids,
141        &emailed_tos,
142        token_ids,
143    )
144    .fetch_all(conn)
145    .await?;
146    Ok(claimed.into_iter().collect())
147}
148
149/// This course's newest linking mail for each of these Sisu people, keyed by person id.
150pub async fn get_latest_by_course_and_persons(
151    conn: &mut PgConnection,
152    course_id: Uuid,
153    sisu_person_ids: &[String],
154) -> ModelResult<HashMap<String, CreditRegistrationAccountLinkingEmail>> {
155    let res = sqlx::query_as!(
156        CreditRegistrationAccountLinkingEmail,
157        r#"
158SELECT DISTINCT ON (sisu_person_id) *
159FROM credit_registration_account_linking_emails
160WHERE course_id = $1
161  AND sisu_person_id = ANY($2::text [])
162  AND deleted_at IS NULL
163ORDER BY sisu_person_id,
164  sent_at DESC
165        "#,
166        course_id,
167        sisu_person_ids,
168    )
169    .fetch_all(conn)
170    .await?;
171    Ok(res
172        .into_iter()
173        .map(|row| (row.sisu_person_id.clone(), row))
174        .collect())
175}
176
177/// This course's linking mails for one student number, newest first.
178///
179/// Keyed on the number the study registry gave us, not on a verified link: the recipients of a
180/// linking mail are exactly the population that has none.
181pub async fn get_by_course_id_and_student_number(
182    conn: &mut PgConnection,
183    course_id: Uuid,
184    student_number: &str,
185) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
186    let res = sqlx::query_as!(
187        CreditRegistrationAccountLinkingEmail,
188        r#"
189SELECT *
190FROM credit_registration_account_linking_emails
191WHERE course_id = $1
192  AND student_number = $2
193  AND deleted_at IS NULL
194ORDER BY sent_at DESC
195        "#,
196        course_id,
197        student_number,
198    )
199    .fetch_all(conn)
200    .await?;
201    Ok(res)
202}
203
204pub async fn get_by_sisu_person_id(
205    conn: &mut PgConnection,
206    sisu_person_id: &str,
207) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
208    let res = sqlx::query_as!(
209        CreditRegistrationAccountLinkingEmail,
210        r#"
211SELECT *
212FROM credit_registration_account_linking_emails
213WHERE sisu_person_id = $1
214  AND deleted_at IS NULL
215ORDER BY sent_at DESC
216        "#,
217        sisu_person_id
218    )
219    .fetch_all(conn)
220    .await?;
221    Ok(res)
222}
223
224/// How many mails this person has had for this course, tokens that expired unused included. Read
225/// against the lifetime cap when an admin asks for a resend.
226pub async fn count_sent_for_person_and_course(
227    conn: &mut PgConnection,
228    sisu_person_id: &str,
229    course_id: Uuid,
230) -> ModelResult<i64> {
231    let count = sqlx::query_scalar!(
232        r#"
233SELECT COUNT(*) AS "count!"
234FROM credit_registration_account_linking_emails
235WHERE sisu_person_id = $1
236  AND course_id = $2
237  AND deleted_at IS NULL
238        "#,
239        sisu_person_id,
240        course_id,
241    )
242    .fetch_one(conn)
243    .await?;
244    Ok(count)
245}
246
247/// A claimed slot with no delivery yet, and everything the mail needs to be written.
248#[derive(Debug, Clone)]
249pub struct LinkingMailToQueue {
250    pub id: Uuid,
251    pub emailed_to: String,
252    pub student_number: String,
253    pub first_names: Option<String>,
254    /// Mailed as part of the link, so the recipient can prove the address is theirs.
255    pub token: DbSecret,
256    pub course_name: String,
257    pub course_language_code: String,
258}
259
260/// Claims slots whose mail has not been queued yet, oldest first.
261///
262/// Locks them, so the caller must hold a transaction: the delivery insert is not idempotent, and two
263/// iterations claiming one slot would queue the same mail twice.
264///
265/// A retired, used or expired token is skipped rather than mailed — a dead link spends the
266/// recipient's one mail for this course on nothing — but its slot stays, since it is still proof we
267/// may not mail that address again.
268pub async fn claim_unqueued(
269    conn: &mut PgConnection,
270    limit: i64,
271    course_id: Option<Uuid>,
272) -> ModelResult<Vec<LinkingMailToQueue>> {
273    let res = sqlx::query_as!(
274        LinkingMailToQueue,
275        r#"
276SELECT e.id AS "id!",
277  e.emailed_to AS "emailed_to!",
278  e.student_number AS "student_number!",
279  t.first_names AS "first_names?",
280  t.token AS "token!: DbSecret",
281  c.name AS "course_name!",
282  c.language_code AS "course_language_code!"
283FROM credit_registration_account_linking_emails e
284  JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
285  JOIN courses c ON c.id = e.course_id
286WHERE e.email_delivery_id IS NULL
287  AND e.deleted_at IS NULL
288  AND t.deleted_at IS NULL
289  AND t.used_at IS NULL
290  AND t.expires_at > now()
291  AND ($2::uuid IS NULL OR e.course_id = $2)
292ORDER BY e.sent_at
293FOR UPDATE OF e SKIP LOCKED
294LIMIT $1
295        "#,
296        limit,
297        course_id,
298    )
299    .fetch_all(conn)
300    .await?;
301    Ok(res)
302}
303
304/// Records which delivery carries this mail, which is also what takes the slot out of the queue.
305pub async fn set_email_delivery_id(
306    conn: &mut PgConnection,
307    id: Uuid,
308    email_delivery_id: Uuid,
309) -> ModelResult<()> {
310    sqlx::query!(
311        r#"
312UPDATE credit_registration_account_linking_emails
313SET email_delivery_id = $2
314WHERE id = $1
315  AND deleted_at IS NULL
316        "#,
317        id,
318        email_delivery_id,
319    )
320    .execute(conn)
321    .await?;
322    Ok(())
323}
324
325/// What we can honestly say about each linking mail. A slot with no delivery yet is `queued`:
326/// anything else would claim a send attempt that never happened.
327pub async fn get_send_status_reports(
328    conn: &mut PgConnection,
329    ids: &[Uuid],
330) -> ModelResult<HashMap<Uuid, EmailSendStatusReport>> {
331    let rows = sqlx::query!(
332        r#"
333SELECT id,
334  email_delivery_id
335FROM credit_registration_account_linking_emails
336WHERE id = ANY($1::uuid [])
337  AND deleted_at IS NULL
338        "#,
339        ids
340    )
341    .fetch_all(&mut *conn)
342    .await?;
343    let delivery_ids: Vec<Uuid> = rows
344        .iter()
345        .filter_map(|row| row.email_delivery_id)
346        .collect();
347    let mut deliveries = get_send_statuses(conn, &delivery_ids).await?;
348    let res = rows
349        .into_iter()
350        .map(|row| {
351            let report = row
352                .email_delivery_id
353                .and_then(|id| deliveries.remove(&id))
354                .unwrap_or_else(not_handed_over_yet);
355            (row.id, report)
356        })
357        .collect();
358    Ok(res)
359}
360
361/// What a claimed slot with no delivery reports. Public so a caller falling back to a default says
362/// the same thing [`get_send_status_reports`] would have.
363pub fn not_handed_over_yet() -> EmailSendStatusReport {
364    EmailSendStatusReport {
365        email_send_status: EmailSendStatus::Queued,
366        sent_at: None,
367        last_attempt_at: None,
368        retry_count: 0,
369        next_retry_at: None,
370        failure_code: None,
371        failure_is_transient: None,
372    }
373}
374
375/// Mails claimed in the window, whatever course they belong to. One row is one address, which is
376/// what the send-rate caps govern.
377pub async fn count_sent_since(conn: &mut PgConnection, since: DateTime<Utc>) -> ModelResult<i64> {
378    let count = sqlx::query_scalar!(
379        r#"
380SELECT COUNT(*) AS "count!"
381FROM credit_registration_account_linking_emails
382WHERE sent_at >= $1
383  AND deleted_at IS NULL
384        "#,
385        since,
386    )
387    .fetch_one(conn)
388    .await?;
389    Ok(count)
390}
391
392/// Mails claimed in the window, newest first, whatever course they belong to.
393pub async fn get_sent_since(
394    conn: &mut PgConnection,
395    since: DateTime<Utc>,
396) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
397    let res = sqlx::query_as!(
398        CreditRegistrationAccountLinkingEmail,
399        r#"
400SELECT *
401FROM credit_registration_account_linking_emails
402WHERE sent_at >= $1
403  AND deleted_at IS NULL
404ORDER BY sent_at DESC
405        "#,
406        since,
407    )
408    .fetch_all(conn)
409    .await?;
410    Ok(res)
411}
412
413/// Every mail in a window, bucketed the way [`crate::email_deliveries::derive_email_send_status`]
414/// does. Computed in SQL from the same facts rather than from its output, so the two cannot drift
415/// on what counts as failed.
416#[derive(Debug, Clone, PartialEq, Default)]
417pub struct LinkingMailSendStatusTotals {
418    pub mails_in_window: i64,
419    pub queued: i64,
420    pub retrying: i64,
421    pub sent: i64,
422    pub send_failed: i64,
423    /// `None` when nothing failed within the window.
424    pub last_send_failed_at: Option<DateTime<Utc>>,
425}
426
427pub async fn get_send_status_totals_since(
428    conn: &mut PgConnection,
429    since: DateTime<Utc>,
430    now: DateTime<Utc>,
431) -> ModelResult<LinkingMailSendStatusTotals> {
432    struct Row {
433        sent_at: DateTime<Utc>,
434        email_delivery_id: Option<Uuid>,
435        delivery_sent: Option<bool>,
436        retryable: Option<bool>,
437        first_failed_at: Option<DateTime<Utc>>,
438        retry_count: Option<i32>,
439    }
440    let rows = sqlx::query_as!(
441        Row,
442        r#"
443SELECT
444  e.sent_at AS "sent_at!",
445  e.email_delivery_id,
446  ed.sent AS delivery_sent,
447  ed.retryable,
448  ed.first_failed_at,
449  ed.retry_count
450FROM credit_registration_account_linking_emails e
451  LEFT JOIN email_deliveries ed ON ed.id = e.email_delivery_id
452WHERE e.sent_at >= $1
453  AND e.deleted_at IS NULL
454        "#,
455        since,
456    )
457    .fetch_all(conn)
458    .await?;
459
460    let mut totals = LinkingMailSendStatusTotals {
461        mails_in_window: rows.len() as i64,
462        ..Default::default()
463    };
464    for row in rows {
465        let status = match row.email_delivery_id {
466            None => EmailSendStatus::Queued,
467            Some(_) => {
468                let facts = EmailSendStatusFacts {
469                    sent: row.delivery_sent.unwrap_or(false),
470                    retryable: row.retryable.unwrap_or(false),
471                    retry_count: row.retry_count.unwrap_or(0),
472                    next_retry_at: None,
473                    first_failed_at: row.first_failed_at,
474                    last_attempt_at: None,
475                    failure_code: None,
476                    failure_is_transient: None,
477                };
478                derive_email_send_status(&facts, now).email_send_status
479            }
480        };
481        match status {
482            EmailSendStatus::Queued => totals.queued += 1,
483            EmailSendStatus::Retrying => totals.retrying += 1,
484            EmailSendStatus::Sent => totals.sent += 1,
485            EmailSendStatus::SendFailed => {
486                totals.send_failed += 1;
487                totals.last_send_failed_at = Some(
488                    totals
489                        .last_send_failed_at
490                        .map_or(row.sent_at, |prev| prev.max(row.sent_at)),
491                );
492            }
493        }
494    }
495    Ok(totals)
496}
497
498#[derive(Debug, Clone, PartialEq)]
499pub struct LinkingMailFailureDomain {
500    pub domain: String,
501    pub count: i64,
502}
503
504/// Domains behind a hard send failure in the window, worst first. Same predicate as
505/// [`get_send_status_totals_since`], enforced by both calling
506/// [`crate::email_deliveries::is_hard_send_failure`] rather than each repeating the condition.
507pub async fn get_send_failure_domains_since(
508    conn: &mut PgConnection,
509    since: DateTime<Utc>,
510    now: DateTime<Utc>,
511) -> ModelResult<Vec<LinkingMailFailureDomain>> {
512    struct Row {
513        emailed_to: String,
514        retryable: bool,
515        first_failed_at: Option<DateTime<Utc>>,
516    }
517    let rows = sqlx::query_as!(
518        Row,
519        r#"
520SELECT e.emailed_to, ed.retryable, ed.first_failed_at
521FROM credit_registration_account_linking_emails e
522  JOIN email_deliveries ed ON ed.id = e.email_delivery_id
523WHERE e.sent_at >= $1
524  AND e.deleted_at IS NULL
525  AND position('@' IN e.emailed_to) > 0
526  AND NOT ed.sent
527        "#,
528        since,
529    )
530    .fetch_all(conn)
531    .await?;
532
533    let mut counts: HashMap<String, i64> = HashMap::new();
534    for row in rows {
535        if !is_hard_send_failure(row.retryable, row.first_failed_at, now) {
536            continue;
537        }
538        let Some(at) = row.emailed_to.find('@') else {
539            continue;
540        };
541        *counts
542            .entry(row.emailed_to[at + 1..].to_string())
543            .or_insert(0) += 1;
544    }
545
546    let mut domains: Vec<LinkingMailFailureDomain> = counts
547        .into_iter()
548        .map(|(domain, count)| LinkingMailFailureDomain { domain, count })
549        .collect();
550    domains.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.domain.cmp(&b.domain)));
551    Ok(domains)
552}
553
554/// Hard send failures for one course, all time. Same predicate as [`get_send_status_totals_since`],
555/// narrowed to a course instead of a time window.
556pub async fn count_send_failed_for_course(
557    conn: &mut PgConnection,
558    course_id: Uuid,
559    now: DateTime<Utc>,
560) -> ModelResult<i64> {
561    struct Row {
562        retryable: bool,
563        first_failed_at: Option<DateTime<Utc>>,
564    }
565    let rows = sqlx::query_as!(
566        Row,
567        r#"
568SELECT ed.retryable, ed.first_failed_at
569FROM credit_registration_account_linking_emails e
570  JOIN email_deliveries ed ON ed.id = e.email_delivery_id
571WHERE e.course_id = $1
572  AND e.deleted_at IS NULL
573  AND NOT ed.sent
574        "#,
575        course_id,
576    )
577    .fetch_all(conn)
578    .await?;
579    Ok(rows
580        .into_iter()
581        .filter(|row| is_hard_send_failure(row.retryable, row.first_failed_at, now))
582        .count() as i64)
583}
584
585/// One person and course mailed to the cap without a single claim: the stale-address population,
586/// which is how "the student is ignoring us" is told from "the address Sisu holds is dead".
587#[derive(Debug, Clone, PartialEq)]
588pub struct StaleUnclaimedLinkingMails {
589    pub student_number: String,
590    pub sisu_person_id: String,
591    pub course_id: Uuid,
592    pub course_name: String,
593    pub mail_count: i64,
594    pub first_sent_at: DateTime<Utc>,
595    pub last_sent_at: DateTime<Utc>,
596    pub mail_ids: Vec<Uuid>,
597    /// In full: an admin deciding whether resending can work has to read the address.
598    pub addresses: Vec<String>,
599}
600
601pub async fn get_stale_unclaimed(
602    conn: &mut PgConnection,
603    min_mail_count: i64,
604    limit: i64,
605) -> ModelResult<Vec<StaleUnclaimedLinkingMails>> {
606    let res = sqlx::query_as!(
607        StaleUnclaimedLinkingMails,
608        r#"
609SELECT e.student_number AS "student_number!",
610  e.sisu_person_id AS "sisu_person_id!",
611  e.course_id AS "course_id!",
612  c.name AS "course_name!",
613  COUNT(*) AS "mail_count!",
614  MIN(e.sent_at) AS "first_sent_at!",
615  MAX(e.sent_at) AS "last_sent_at!",
616  ARRAY_AGG(
617    e.id
618    ORDER BY e.sent_at
619  ) AS "mail_ids!",
620  ARRAY_AGG(
621    e.emailed_to
622    ORDER BY e.sent_at
623  ) AS "addresses!"
624FROM credit_registration_account_linking_emails e
625  JOIN courses c ON c.id = e.course_id
626WHERE e.deleted_at IS NULL
627  AND NOT EXISTS (
628    SELECT 1
629    FROM verified_student_numbers vsn
630    WHERE vsn.sisu_person_id = e.sisu_person_id
631      AND vsn.deleted_at IS NULL
632  )
633GROUP BY e.student_number,
634  e.sisu_person_id,
635  e.course_id,
636  c.name
637HAVING COUNT(*) >= $1
638ORDER BY MAX(e.sent_at) DESC
639LIMIT $2
640        "#,
641        min_mail_count,
642        limit,
643    )
644    .fetch_all(conn)
645    .await?;
646    Ok(res)
647}
648
649/// Lets a rate-cap override or an admin resend mail the same address again.
650pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
651    soft_delete_batch(conn, std::slice::from_ref(&id)).await
652}
653
654/// Batch form of [`soft_delete`]: one `UPDATE` for every row instead of one per id.
655pub async fn soft_delete_batch(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
656    if ids.is_empty() {
657        return Ok(());
658    }
659    sqlx::query!(
660        r#"
661UPDATE credit_registration_account_linking_emails
662SET deleted_at = now()
663WHERE id = ANY($1)
664  AND deleted_at IS NULL
665        "#,
666        ids
667    )
668    .execute(conn)
669    .await?;
670    Ok(())
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::email_deliveries::insert_email_delivery_to_address;
677    use crate::email_templates::{EmailTemplateNew, EmailTemplateType, insert_email_template};
678    use crate::library::credit_registration::account_linking::{
679        DiscoveredPerson, claim_linking_mails,
680    };
681    use crate::test_helper::*;
682
683    async fn claim_a_mail(conn: &mut PgConnection, course_id: Uuid) -> Uuid {
684        claim_linking_mails(
685            conn,
686            &DiscoveredPerson {
687                sisu_person_id: "hy-hlo-1".to_string(),
688                student_number: "012345678".to_string(),
689                first_names: Some("Aada".to_string()),
690                last_name: Some("Virtanen".to_string()),
691                course_id,
692                addresses: vec!["aada@helsinki.fi".to_string()],
693            },
694        )
695        .await
696        .unwrap();
697        get_by_sisu_person_id(conn, "hy-hlo-1")
698            .await
699            .unwrap()
700            .pop()
701            .expect("the claim wrote a slot")
702            .id
703    }
704
705    async fn seed_template(conn: &mut PgConnection) -> Uuid {
706        insert_email_template(
707            conn,
708            None,
709            EmailTemplateNew {
710                template_type: EmailTemplateType::CreditRegistrationAccountLinking,
711                language: Some("en".to_string()),
712                content: Some(serde_json::json!([])),
713                subject: Some("Link your student number".to_string()),
714            },
715            None,
716        )
717        .await
718        .unwrap()
719        .id
720    }
721
722    /// The property the two-phase split rests on: without it a restart between the claim and the
723    /// queueing would mail the same address again.
724    #[tokio::test]
725    async fn a_claimed_slot_leaves_the_queue_once_its_delivery_exists() {
726        insert_data!(:tx, :user, :org, :course);
727        let slot = claim_a_mail(tx.as_mut(), course).await;
728        let template = seed_template(tx.as_mut()).await;
729
730        let claimed = claim_unqueued(tx.as_mut(), 10, None).await.unwrap();
731        assert_eq!(claimed.len(), 1);
732        assert_eq!(claimed[0].id, slot);
733        assert_eq!(claimed[0].emailed_to, "aada@helsinki.fi");
734
735        let delivery = insert_email_delivery_to_address(
736            tx.as_mut(),
737            &claimed[0].emailed_to,
738            template,
739            &serde_json::json!({ "NAME": "Aada" }),
740        )
741        .await
742        .unwrap();
743        set_email_delivery_id(tx.as_mut(), slot, delivery)
744            .await
745            .unwrap();
746
747        assert!(
748            claim_unqueued(tx.as_mut(), 10, None)
749                .await
750                .unwrap()
751                .is_empty()
752        );
753    }
754
755    #[tokio::test]
756    async fn a_slot_with_no_delivery_yet_reports_as_queued() {
757        insert_data!(:tx, :user, :org, :course);
758        let slot = claim_a_mail(tx.as_mut(), course).await;
759        let reports = get_send_status_reports(tx.as_mut(), &[slot]).await.unwrap();
760        assert_eq!(
761            reports.get(&slot).map(|report| report.email_send_status),
762            Some(EmailSendStatus::Queued)
763        );
764    }
765}