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. A row holds its address only while its link can still be used; once
7//! the link has expired or been used, a later mail to the address replaces it.
8use std::collections::{HashMap, HashSet};
9
10use secrecy::ExposeSecret;
11use utoipa::ToSchema;
12
13use crate::email_deliveries::{
14    EmailSendStatus, EmailSendStatusFacts, EmailSendStatusReport, derive_email_send_status,
15    get_send_statuses, is_hard_send_failure,
16};
17use crate::prelude::*;
18
19#[derive(Debug, Clone)]
20pub struct CreditRegistrationAccountLinkingEmail {
21    pub id: Uuid,
22    pub created_at: DateTime<Utc>,
23    pub updated_at: DateTime<Utc>,
24    pub deleted_at: Option<DateTime<Utc>>,
25    pub student_number: DbSecret,
26    pub sisu_person_id: DbSecret,
27    pub course_id: Uuid,
28    pub emailed_to: DbSecret,
29    pub student_number_verification_token_id: Option<Uuid>,
30    pub email_delivery_id: Option<Uuid>,
31    pub sent_at: DateTime<Utc>,
32    /// Set once this row gave up its place in the dedup key: to a later mail to the same address,
33    /// or when the person was unlinked.
34    pub replaced_at: Option<DateTime<Utc>>,
35}
36
37#[derive(Debug, Clone)]
38pub struct NewAccountLinkingEmail {
39    pub student_number: DbSecret,
40    pub sisu_person_id: DbSecret,
41    pub course_id: Uuid,
42    pub emailed_to: DbSecret,
43    pub student_number_verification_token_id: Option<Uuid>,
44    pub email_delivery_id: Option<Uuid>,
45}
46
47/// Claims the right to mail this (person, course, address); `None` means another row holds the
48/// address and the caller must not send. Call in the transaction that mints the token, so a refused
49/// claim leaves no usable link.
50pub async fn claim_send_slot(
51    conn: &mut PgConnection,
52    new: &NewAccountLinkingEmail,
53) -> ModelResult<Option<Uuid>> {
54    let res = sqlx::query!(
55        r#"
56INSERT INTO credit_registration_account_linking_emails (
57    student_number,
58    sisu_person_id,
59    course_id,
60    emailed_to,
61    student_number_verification_token_id,
62    email_delivery_id
63  )
64VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING
65RETURNING id
66        "#,
67        new.student_number.expose_secret(),
68        new.sisu_person_id.expose_secret(),
69        new.course_id,
70        new.emailed_to.expose_secret(),
71        new.student_number_verification_token_id,
72        new.email_delivery_id,
73    )
74    .fetch_optional(conn)
75    .await?;
76    Ok(res.map(|r| r.id))
77}
78
79/// Moves a mail's `sent_at` `age` into the past, for seeding mails sent before today.
80///
81/// Shifts the row rather than the clock because the caps compare against Postgres `now()`.
82pub async fn backdate(conn: &mut PgConnection, id: Uuid, age: chrono::Duration) -> ModelResult<()> {
83    sqlx::query!(
84        "UPDATE credit_registration_account_linking_emails SET sent_at = now() - $2::interval WHERE id = $1",
85        id,
86        age as chrono::Duration
87    )
88    .execute(conn)
89    .await?;
90    Ok(())
91}
92
93/// As much of one existing mail as the dedup guard and the rate caps need to decide on a person.
94#[derive(Debug, Clone)]
95pub struct ExistingLinkingMailFact {
96    pub sisu_person_id: DbSecret,
97    pub course_id: Uuid,
98    pub emailed_to: DbSecret,
99    pub sent_at: DateTime<Utc>,
100    /// Whether the row still holds its address: not replaced, and its link can still be used. A row
101    /// with no token holds it for good.
102    pub holds_address: bool,
103}
104
105/// Every live mail these people have ever been sent, any course: one query stands in for the
106/// quiet-period count, the per-course count and the dedup check of a whole batch of candidates.
107pub async fn get_existing_facts_for_persons(
108    conn: &mut PgConnection,
109    sisu_person_ids: &[String],
110) -> ModelResult<Vec<ExistingLinkingMailFact>> {
111    let res = sqlx::query_as!(
112        ExistingLinkingMailFact,
113        r#"
114SELECT e.sisu_person_id AS "sisu_person_id!",
115  e.course_id AS "course_id!",
116  e.emailed_to AS "emailed_to!",
117  e.sent_at AS "sent_at!",
118  (
119    e.replaced_at IS NULL
120    AND (
121      e.student_number_verification_token_id IS NULL
122      OR is_usable_verification_token(t)
123    )
124  ) AS "holds_address!"
125FROM credit_registration_account_linking_emails e
126  LEFT JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
127WHERE e.sisu_person_id = ANY($1::text [])
128  AND e.deleted_at IS NULL
129        "#,
130        sisu_person_ids
131    )
132    .fetch_all(conn)
133    .await?;
134    Ok(res)
135}
136
137/// How many linking mails one course has sent each of these people, replaced ones included.
138pub async fn count_mails_per_person_for_course(
139    conn: &mut PgConnection,
140    sisu_person_ids: &[String],
141    course_id: Uuid,
142) -> ModelResult<HashMap<String, i64>> {
143    let res = sqlx::query!(
144        r#"
145SELECT sisu_person_id AS "sisu_person_id!",
146  COUNT(*) AS "mail_count!"
147FROM credit_registration_account_linking_emails
148WHERE sisu_person_id = ANY($1::text [])
149  AND course_id = $2
150  AND deleted_at IS NULL
151GROUP BY sisu_person_id
152        "#,
153        sisu_person_ids,
154        course_id
155    )
156    .fetch_all(conn)
157    .await?;
158    Ok(res
159        .into_iter()
160        .map(|row| {
161            (
162                row.sisu_person_id.expose_secret().to_owned(),
163                row.mail_count,
164            )
165        })
166        .collect())
167}
168
169/// Marks the rows these new slots would collide with in the dedup key as replaced, where their link
170/// can no longer be used, so the slots can be claimed. Rows holding their address are left alone.
171pub async fn replace_lapsed_slots(
172    conn: &mut PgConnection,
173    new: &[NewAccountLinkingEmail],
174) -> ModelResult<()> {
175    if new.is_empty() {
176        return Ok(());
177    }
178    let sisu_person_ids: Vec<String> = new
179        .iter()
180        .map(|n| n.sisu_person_id.expose_secret().to_owned())
181        .collect();
182    let course_ids: Vec<Uuid> = new.iter().map(|n| n.course_id).collect();
183    let emailed_tos: Vec<String> = new
184        .iter()
185        .map(|n| n.emailed_to.expose_secret().to_owned())
186        .collect();
187    sqlx::query!(
188        r#"
189UPDATE credit_registration_account_linking_emails e
190SET replaced_at = now()
191FROM UNNEST($1::text [], $2::uuid [], $3::text []) AS slot(sisu_person_id, course_id, emailed_to)
192WHERE e.sisu_person_id = slot.sisu_person_id
193  AND e.course_id = slot.course_id
194  AND LOWER(e.emailed_to) = LOWER(slot.emailed_to)
195  AND e.deleted_at IS NULL
196  AND e.replaced_at IS NULL
197  AND e.student_number_verification_token_id IS NOT NULL
198  AND NOT EXISTS (
199    SELECT 1
200    FROM student_number_verification_tokens t
201    WHERE t.id = e.student_number_verification_token_id
202      AND is_usable_verification_token(t)
203  )
204        "#,
205        &sisu_person_ids,
206        &course_ids,
207        &emailed_tos,
208    )
209    .execute(conn)
210    .await?;
211    Ok(())
212}
213
214/// Batched form of [`claim_send_slot`], keyed by token: returns the
215/// `student_number_verification_token_id` of every row actually inserted, so the caller can tell
216/// which candidates lost the race to the unique index.
217///
218/// `token_ids[i]` is the token already minted for `new[i]`, taken separately rather than read from
219/// `new[i].student_number_verification_token_id`: that field stays `Option` because it really can be
220/// absent on the single-row [`claim_send_slot`], but a batched claim always has one.
221pub async fn claim_send_slots(
222    conn: &mut PgConnection,
223    new: &[NewAccountLinkingEmail],
224    token_ids: &[Uuid],
225) -> ModelResult<HashSet<Uuid>> {
226    if new.is_empty() {
227        return Ok(HashSet::new());
228    }
229    let student_numbers: Vec<String> = new
230        .iter()
231        .map(|n| n.student_number.expose_secret().to_owned())
232        .collect();
233    let sisu_person_ids: Vec<String> = new
234        .iter()
235        .map(|n| n.sisu_person_id.expose_secret().to_owned())
236        .collect();
237    let course_ids: Vec<Uuid> = new.iter().map(|n| n.course_id).collect();
238    let emailed_tos: Vec<String> = new
239        .iter()
240        .map(|n| n.emailed_to.expose_secret().to_owned())
241        .collect();
242
243    let claimed = sqlx::query_scalar!(
244        r#"
245INSERT INTO credit_registration_account_linking_emails (
246    student_number,
247    sisu_person_id,
248    course_id,
249    emailed_to,
250    student_number_verification_token_id
251  )
252SELECT * FROM UNNEST($1::text [], $2::text [], $3::uuid [], $4::text [], $5::uuid []) ON CONFLICT DO NOTHING
253RETURNING student_number_verification_token_id AS "token_id!"
254        "#,
255        &student_numbers,
256        &sisu_person_ids,
257        &course_ids,
258        &emailed_tos,
259        token_ids,
260    )
261    .fetch_all(conn)
262    .await?;
263    Ok(claimed.into_iter().collect())
264}
265
266/// This course's newest linking mail for each of these Sisu people, keyed by person id.
267pub async fn get_latest_by_course_and_persons(
268    conn: &mut PgConnection,
269    course_id: Uuid,
270    sisu_person_ids: &[String],
271) -> ModelResult<HashMap<String, CreditRegistrationAccountLinkingEmail>> {
272    let res = sqlx::query_as!(
273        CreditRegistrationAccountLinkingEmail,
274        r#"
275SELECT DISTINCT ON (sisu_person_id) *
276FROM credit_registration_account_linking_emails
277WHERE course_id = $1
278  AND sisu_person_id = ANY($2::text [])
279  AND deleted_at IS NULL
280ORDER BY sisu_person_id,
281  sent_at DESC
282        "#,
283        course_id,
284        sisu_person_ids,
285    )
286    .fetch_all(conn)
287    .await?;
288    Ok(res
289        .into_iter()
290        .map(|row| (row.sisu_person_id.expose_secret().to_owned(), row))
291        .collect())
292}
293
294/// This course's linking mails for one student number, newest first.
295///
296/// Keyed on the number the study registry gave us, not on a verified link: the recipients of a
297/// linking mail are exactly the population that has none.
298pub async fn get_by_course_id_and_student_number(
299    conn: &mut PgConnection,
300    course_id: Uuid,
301    student_number: &str,
302) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
303    let res = sqlx::query_as!(
304        CreditRegistrationAccountLinkingEmail,
305        r#"
306SELECT *
307FROM credit_registration_account_linking_emails
308WHERE course_id = $1
309  AND student_number = $2
310  AND deleted_at IS NULL
311ORDER BY sent_at DESC
312        "#,
313        course_id,
314        student_number,
315    )
316    .fetch_all(conn)
317    .await?;
318    Ok(res)
319}
320
321pub async fn get_by_sisu_person_id(
322    conn: &mut PgConnection,
323    sisu_person_id: &str,
324) -> ModelResult<Vec<CreditRegistrationAccountLinkingEmail>> {
325    let res = sqlx::query_as!(
326        CreditRegistrationAccountLinkingEmail,
327        r#"
328SELECT *
329FROM credit_registration_account_linking_emails
330WHERE sisu_person_id = $1
331  AND deleted_at IS NULL
332ORDER BY sent_at DESC
333        "#,
334        sisu_person_id
335    )
336    .fetch_all(conn)
337    .await?;
338    Ok(res)
339}
340
341/// How many mails this person has had for this course, replaced ones included. Read against the
342/// lifetime cap when an admin asks for a resend.
343pub async fn count_sent_for_person_and_course(
344    conn: &mut PgConnection,
345    sisu_person_id: &str,
346    course_id: Uuid,
347) -> ModelResult<i64> {
348    let count = sqlx::query_scalar!(
349        r#"
350SELECT COUNT(*) AS "count!"
351FROM credit_registration_account_linking_emails
352WHERE sisu_person_id = $1
353  AND course_id = $2
354  AND deleted_at IS NULL
355        "#,
356        sisu_person_id,
357        course_id,
358    )
359    .fetch_one(conn)
360    .await?;
361    Ok(count)
362}
363
364/// A claimed slot with no delivery yet, and everything the mail needs to be written.
365#[derive(Debug, Clone)]
366pub struct LinkingMailToQueue {
367    pub id: Uuid,
368    pub emailed_to: DbSecret,
369    pub student_number: DbSecret,
370    pub first_names: Option<DbSecret>,
371    /// Mailed as part of the link, so the recipient can prove the address is theirs.
372    pub token: DbSecret,
373    pub course_name: String,
374    pub course_language_code: String,
375}
376
377/// Claims slots whose mail has not been queued yet, oldest first.
378///
379/// Locks them, so the caller must hold a transaction: the delivery insert is not idempotent, and two
380/// iterations claiming one slot would queue the same mail twice.
381///
382/// A retired, used or expired token is skipped rather than mailed — a dead link would spend one of
383/// the recipient's mails for this course on nothing — but its slot stays, counting against the caps.
384pub async fn claim_unqueued(
385    conn: &mut PgConnection,
386    limit: i64,
387    course_id: Option<Uuid>,
388) -> ModelResult<Vec<LinkingMailToQueue>> {
389    let res = sqlx::query_as!(
390        LinkingMailToQueue,
391        r#"
392SELECT e.id AS "id!",
393  e.emailed_to AS "emailed_to!",
394  e.student_number AS "student_number!",
395  t.first_names AS "first_names?",
396  t.token AS "token!: DbSecret",
397  c.name AS "course_name!",
398  c.language_code AS "course_language_code!"
399FROM credit_registration_account_linking_emails e
400  JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
401  JOIN courses c ON c.id = e.course_id AND c.deleted_at IS NULL
402WHERE e.email_delivery_id IS NULL
403  AND e.deleted_at IS NULL
404  AND is_usable_verification_token(t)
405  AND ($2::uuid IS NULL OR e.course_id = $2)
406ORDER BY e.sent_at
407FOR UPDATE OF e SKIP LOCKED
408LIMIT $1
409        "#,
410        limit,
411        course_id,
412    )
413    .fetch_all(conn)
414    .await?;
415    Ok(res)
416}
417
418/// Records which delivery carries this mail, which is also what takes the slot out of the queue.
419pub async fn set_email_delivery_id(
420    conn: &mut PgConnection,
421    id: Uuid,
422    email_delivery_id: Uuid,
423) -> ModelResult<()> {
424    sqlx::query!(
425        r#"
426UPDATE credit_registration_account_linking_emails
427SET email_delivery_id = $2
428WHERE id = $1
429  AND deleted_at IS NULL
430        "#,
431        id,
432        email_delivery_id,
433    )
434    .execute(conn)
435    .await?;
436    Ok(())
437}
438
439/// Whether the link a linking mail carries can still be opened.
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
441#[serde(rename_all = "snake_case")]
442pub enum LinkingMailLinkState {
443    /// Also a mail with no token, which has no link to lapse.
444    Usable,
445    Used,
446    /// Expired, or retired unused.
447    Lapsed,
448}
449
450impl LinkingMailLinkState {
451    /// The state a mail's link is in, given whether it can still be used and whether it was used.
452    fn of(is_usable: bool, is_used: bool) -> Self {
453        if is_usable {
454            Self::Usable
455        } else if is_used {
456            Self::Used
457        } else {
458            Self::Lapsed
459        }
460    }
461}
462
463/// The state of each mail's link, by mail id.
464pub async fn get_link_states(
465    conn: &mut PgConnection,
466    mail_ids: &[Uuid],
467) -> ModelResult<HashMap<Uuid, LinkingMailLinkState>> {
468    let rows = sqlx::query!(
469        r#"
470SELECT e.id AS "id!",
471  (
472    e.student_number_verification_token_id IS NULL
473    OR is_usable_verification_token(t)
474  ) AS "is_usable!",
475  t.used_at IS NOT NULL AS "is_used!"
476FROM credit_registration_account_linking_emails e
477  LEFT JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
478WHERE e.id = ANY($1::uuid [])
479        "#,
480        mail_ids,
481    )
482    .fetch_all(conn)
483    .await?;
484    Ok(rows
485        .into_iter()
486        .map(|row| (row.id, LinkingMailLinkState::of(row.is_usable, row.is_used)))
487        .collect())
488}
489
490/// What we can honestly say about each linking mail. A slot with no delivery yet is `queued`:
491/// anything else would claim a send attempt that never happened.
492pub async fn get_send_status_reports(
493    conn: &mut PgConnection,
494    ids: &[Uuid],
495) -> ModelResult<HashMap<Uuid, EmailSendStatusReport>> {
496    let rows = sqlx::query!(
497        r#"
498SELECT id,
499  email_delivery_id
500FROM credit_registration_account_linking_emails
501WHERE id = ANY($1::uuid [])
502  AND deleted_at IS NULL
503        "#,
504        ids
505    )
506    .fetch_all(&mut *conn)
507    .await?;
508    let delivery_ids: Vec<Uuid> = rows
509        .iter()
510        .filter_map(|row| row.email_delivery_id)
511        .collect();
512    let mut deliveries = get_send_statuses(conn, &delivery_ids).await?;
513    let res = rows
514        .into_iter()
515        .map(|row| {
516            let report = row
517                .email_delivery_id
518                .and_then(|id| deliveries.remove(&id))
519                .unwrap_or_else(not_handed_over_yet);
520            (row.id, report)
521        })
522        .collect();
523    Ok(res)
524}
525
526/// What a claimed slot with no delivery reports. Public so a caller falling back to a default says
527/// the same thing [`get_send_status_reports`] would have.
528pub fn not_handed_over_yet() -> EmailSendStatusReport {
529    EmailSendStatusReport {
530        email_send_status: EmailSendStatus::Queued,
531        sent_at: None,
532        last_attempt_at: None,
533        retry_count: 0,
534        next_retry_at: None,
535        failure_code: None,
536        failure_is_transient: None,
537    }
538}
539
540/// Mails claimed in the window, whatever course they belong to. One row is one address, which is
541/// what the send-rate caps govern.
542pub async fn count_sent_since(conn: &mut PgConnection, since: DateTime<Utc>) -> ModelResult<i64> {
543    let count = sqlx::query_scalar!(
544        r#"
545SELECT COUNT(*) AS "count!"
546FROM credit_registration_account_linking_emails
547WHERE sent_at >= $1
548  AND deleted_at IS NULL
549        "#,
550        since,
551    )
552    .fetch_one(conn)
553    .await?;
554    Ok(count)
555}
556
557/// Every mail in a window, bucketed the way [`crate::email_deliveries::derive_email_send_status`]
558/// does. Computed in SQL from the same facts rather than from its output, so the two cannot drift
559/// on what counts as failed.
560#[derive(Debug, Clone, PartialEq, Default)]
561pub struct LinkingMailSendStatusTotals {
562    pub mails_in_window: i64,
563    /// Claimed, with no delivery yet: the `link-emails` phase has not queued them.
564    pub waiting_for_link_emails: i64,
565    /// Queued as a delivery the email worker has not attempted yet.
566    pub waiting_for_email_worker: i64,
567    pub retrying: i64,
568    pub sent: i64,
569    pub send_failed: i64,
570    /// Of `mails_in_window`, those whose link has been used.
571    pub used: i64,
572    /// `None` when nothing failed within the window.
573    pub last_send_failed_at: Option<DateTime<Utc>>,
574}
575
576pub async fn get_send_status_totals_since(
577    conn: &mut PgConnection,
578    since: DateTime<Utc>,
579    now: DateTime<Utc>,
580) -> ModelResult<LinkingMailSendStatusTotals> {
581    struct Row {
582        sent_at: DateTime<Utc>,
583        email_delivery_id: Option<Uuid>,
584        delivery_sent: Option<bool>,
585        retryable: Option<bool>,
586        first_failed_at: Option<DateTime<Utc>>,
587        retry_count: Option<i32>,
588        is_used: bool,
589    }
590    let rows = sqlx::query_as!(
591        Row,
592        r#"
593SELECT
594  e.sent_at AS "sent_at!",
595  e.email_delivery_id,
596  ed.sent AS "delivery_sent?",
597  ed.retryable AS "retryable?",
598  ed.first_failed_at AS "first_failed_at?",
599  ed.retry_count AS "retry_count?",
600  COALESCE(t.used_at IS NOT NULL, FALSE) AS "is_used!"
601FROM credit_registration_account_linking_emails e
602  LEFT JOIN email_deliveries ed ON ed.id = e.email_delivery_id AND ed.deleted_at IS NULL
603  LEFT JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
604WHERE e.sent_at >= $1
605  AND e.deleted_at IS NULL
606        "#,
607        since,
608    )
609    .fetch_all(conn)
610    .await?;
611
612    let mut totals = LinkingMailSendStatusTotals {
613        mails_in_window: rows.len() as i64,
614        ..Default::default()
615    };
616    for row in rows {
617        totals.used += i64::from(row.is_used);
618        let status = row.email_delivery_id.map(|_| {
619            let facts = EmailSendStatusFacts {
620                sent: row.delivery_sent.unwrap_or(false),
621                retryable: row.retryable.unwrap_or(false),
622                retry_count: row.retry_count.unwrap_or(0),
623                next_retry_at: None,
624                first_failed_at: row.first_failed_at,
625                last_attempt_at: None,
626                failure_code: None,
627                failure_is_transient: None,
628            };
629            derive_email_send_status(&facts, now).email_send_status
630        });
631        match status {
632            None => totals.waiting_for_link_emails += 1,
633            Some(EmailSendStatus::Queued) => totals.waiting_for_email_worker += 1,
634            Some(EmailSendStatus::Retrying) => totals.retrying += 1,
635            Some(EmailSendStatus::Sent) => totals.sent += 1,
636            Some(EmailSendStatus::SendFailed) => {
637                totals.send_failed += 1;
638                totals.last_send_failed_at = Some(
639                    totals
640                        .last_send_failed_at
641                        .map_or(row.sent_at, |prev| prev.max(row.sent_at)),
642                );
643            }
644        }
645    }
646    Ok(totals)
647}
648
649#[derive(Debug, Clone, PartialEq)]
650pub struct LinkingMailFailureDomain {
651    pub domain: String,
652    pub count: i64,
653}
654
655/// Domains behind a hard send failure in the window, worst first. Same predicate as
656/// [`get_send_status_totals_since`], enforced by both calling
657/// [`crate::email_deliveries::is_hard_send_failure`] rather than each repeating the condition.
658pub async fn get_send_failure_domains_since(
659    conn: &mut PgConnection,
660    since: DateTime<Utc>,
661    now: DateTime<Utc>,
662) -> ModelResult<Vec<LinkingMailFailureDomain>> {
663    struct Row {
664        emailed_to: DbSecret,
665        retryable: bool,
666        first_failed_at: Option<DateTime<Utc>>,
667    }
668    let rows = sqlx::query_as!(
669        Row,
670        r#"
671SELECT e.emailed_to, ed.retryable, ed.first_failed_at
672FROM credit_registration_account_linking_emails e
673  JOIN email_deliveries ed ON ed.id = e.email_delivery_id AND ed.deleted_at IS NULL
674WHERE e.sent_at >= $1
675  AND e.deleted_at IS NULL
676  AND position('@' IN e.emailed_to) > 0
677  AND NOT ed.sent
678        "#,
679        since,
680    )
681    .fetch_all(conn)
682    .await?;
683
684    let mut counts: HashMap<String, i64> = HashMap::new();
685    for row in rows {
686        if !is_hard_send_failure(row.retryable, row.first_failed_at, now) {
687            continue;
688        }
689        let emailed_to = row.emailed_to.expose_secret();
690        let Some(at) = emailed_to.find('@') else {
691            continue;
692        };
693        *counts.entry(emailed_to[at + 1..].to_string()).or_insert(0) += 1;
694    }
695
696    let mut domains: Vec<LinkingMailFailureDomain> = counts
697        .into_iter()
698        .map(|(domain, count)| LinkingMailFailureDomain { domain, count })
699        .collect();
700    domains.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.domain.cmp(&b.domain)));
701    Ok(domains)
702}
703
704/// Hard send failures for one course, all time. Same predicate as [`get_send_status_totals_since`],
705/// narrowed to a course instead of a time window.
706pub async fn count_send_failed_for_course(
707    conn: &mut PgConnection,
708    course_id: Uuid,
709    now: DateTime<Utc>,
710) -> ModelResult<i64> {
711    struct Row {
712        retryable: bool,
713        first_failed_at: Option<DateTime<Utc>>,
714    }
715    let rows = sqlx::query_as!(
716        Row,
717        r#"
718SELECT ed.retryable, ed.first_failed_at
719FROM credit_registration_account_linking_emails e
720  JOIN email_deliveries ed ON ed.id = e.email_delivery_id AND ed.deleted_at IS NULL
721WHERE e.course_id = $1
722  AND e.deleted_at IS NULL
723  AND NOT ed.sent
724        "#,
725        course_id,
726    )
727    .fetch_all(conn)
728    .await?;
729    Ok(rows
730        .into_iter()
731        .filter(|row| is_hard_send_failure(row.retryable, row.first_failed_at, now))
732        .count() as i64)
733}
734
735/// One person and course mailed to the cap without a single claim: the stale-address population,
736/// which is how "the student is ignoring us" is told from "the address Sisu holds is dead".
737#[derive(Debug, Clone)]
738pub struct StaleUnclaimedLinkingMails {
739    pub student_number: DbSecret,
740    pub sisu_person_id: DbSecret,
741    pub course_id: Uuid,
742    pub course_name: String,
743    pub mail_count: i64,
744    pub first_sent_at: DateTime<Utc>,
745    pub last_sent_at: DateTime<Utc>,
746    pub mail_ids: Vec<Uuid>,
747    /// In full: an admin deciding whether resending can work has to read the address.
748    pub addresses: Vec<DbSecret>,
749}
750
751pub async fn get_stale_unclaimed(
752    conn: &mut PgConnection,
753    min_mail_count: i64,
754    limit: i64,
755) -> ModelResult<Vec<StaleUnclaimedLinkingMails>> {
756    let res = sqlx::query_as!(
757        StaleUnclaimedLinkingMails,
758        r#"
759SELECT e.student_number AS "student_number!",
760  e.sisu_person_id AS "sisu_person_id!",
761  e.course_id AS "course_id!",
762  c.name AS "course_name!",
763  COUNT(*) AS "mail_count!",
764  MIN(e.sent_at) AS "first_sent_at!",
765  MAX(e.sent_at) AS "last_sent_at!",
766  ARRAY_AGG(
767    e.id
768    ORDER BY e.sent_at
769  ) AS "mail_ids!",
770  ARRAY_AGG(
771    e.emailed_to
772    ORDER BY e.sent_at
773  ) AS "addresses!: Vec<DbSecret>"
774FROM credit_registration_account_linking_emails e
775  JOIN courses c ON c.id = e.course_id AND c.deleted_at IS NULL
776WHERE e.deleted_at IS NULL
777  AND NOT EXISTS (
778    SELECT 1
779    FROM verified_student_numbers vsn
780    WHERE vsn.sisu_person_id = e.sisu_person_id
781      AND vsn.deleted_at IS NULL
782  )
783GROUP BY e.student_number,
784  e.sisu_person_id,
785  e.course_id,
786  c.name
787HAVING COUNT(*) >= $1
788ORDER BY MAX(e.sent_at) DESC
789LIMIT $2
790        "#,
791        min_mail_count,
792        limit,
793    )
794    .fetch_all(conn)
795    .await?;
796    Ok(res)
797}
798
799/// Lets a rate-cap override or an admin resend mail the same address again.
800pub async fn soft_delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
801    soft_delete_batch(conn, std::slice::from_ref(&id)).await
802}
803
804/// Batch form of [`soft_delete`]: one `UPDATE` for every row instead of one per id.
805pub async fn soft_delete_batch(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
806    if ids.is_empty() {
807        return Ok(());
808    }
809    sqlx::query!(
810        r#"
811UPDATE credit_registration_account_linking_emails
812SET deleted_at = now()
813WHERE id = ANY($1)
814  AND deleted_at IS NULL
815        "#,
816        ids
817    )
818    .execute(conn)
819    .await?;
820    Ok(())
821}
822
823/// Voids the unused links in the mails about the person behind a verified link, and marks those
824/// mails replaced, so that once unlinked the person can be mailed again. The mails still count
825/// against the caps.
826pub async fn retire_unused_for_link(
827    conn: &mut PgConnection,
828    verified_student_number_id: Uuid,
829) -> ModelResult<()> {
830    sqlx::query!(
831        r#"
832WITH retired AS (
833  UPDATE credit_registration_account_linking_emails e
834  SET replaced_at = now()
835  FROM verified_student_numbers vsn
836  WHERE vsn.id = $1
837    AND e.deleted_at IS NULL
838    AND e.replaced_at IS NULL
839    AND (
840      e.sisu_person_id = vsn.sisu_person_id
841      OR e.student_number = vsn.student_number
842    )
843    AND NOT EXISTS (
844      SELECT 1
845      FROM student_number_verification_tokens t
846      WHERE t.id = e.student_number_verification_token_id
847        AND t.used_at IS NOT NULL
848    )
849  RETURNING e.student_number_verification_token_id
850)
851UPDATE student_number_verification_tokens t
852SET deleted_at = now()
853FROM retired
854WHERE t.id = retired.student_number_verification_token_id
855  AND t.deleted_at IS NULL
856        "#,
857        verified_student_number_id,
858    )
859    .execute(conn)
860    .await?;
861    Ok(())
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867    use crate::email_deliveries::insert_email_delivery_to_address;
868    use crate::email_templates::{EmailTemplateNew, EmailTemplateType, insert_email_template};
869    use crate::library::credit_registration::account_linking::{
870        DiscoveredPerson, claim_linking_mail,
871    };
872    use crate::test_helper::*;
873
874    async fn claim_a_mail(conn: &mut PgConnection, course_id: Uuid) -> Uuid {
875        claim_linking_mail(
876            conn,
877            &DiscoveredPerson {
878                sisu_person_id: "hy-hlo-1".to_string().into(),
879                student_number: "012345678".to_string().into(),
880                first_names: Some("Aada".to_string().into()),
881                last_name: Some("Virtanen".to_string().into()),
882                course_id,
883                address: Some(DbSecret::new("aada@example.com")),
884            },
885        )
886        .await
887        .unwrap();
888        get_by_sisu_person_id(conn, "hy-hlo-1")
889            .await
890            .unwrap()
891            .pop()
892            .expect("the claim wrote a slot")
893            .id
894    }
895
896    async fn seed_template(conn: &mut PgConnection) -> Uuid {
897        insert_email_template(
898            conn,
899            None,
900            EmailTemplateNew {
901                template_type: EmailTemplateType::CreditRegistrationAccountLinking,
902                language: Some("en".to_string()),
903                content: Some(serde_json::json!([])),
904                subject: Some("Link your student number".to_string()),
905            },
906            None,
907        )
908        .await
909        .unwrap()
910        .id
911    }
912
913    /// The property the two-phase split rests on: without it a restart between the claim and the
914    /// queueing would mail the same address again.
915    #[tokio::test]
916    async fn a_claimed_slot_leaves_the_queue_once_its_delivery_exists() {
917        insert_data!(:tx, :user, :org, :course);
918        let slot = claim_a_mail(tx.as_mut(), course).await;
919        let template = seed_template(tx.as_mut()).await;
920
921        let claimed = claim_unqueued(tx.as_mut(), 10, None).await.unwrap();
922        assert_eq!(claimed.len(), 1);
923        assert_eq!(claimed[0].id, slot);
924        assert_eq!(claimed[0].emailed_to.expose_secret(), "aada@example.com");
925
926        let delivery = insert_email_delivery_to_address(
927            tx.as_mut(),
928            claimed[0].emailed_to.expose_secret(),
929            template,
930            &serde_json::json!({ "NAME": "Aada" }),
931        )
932        .await
933        .unwrap();
934        set_email_delivery_id(tx.as_mut(), slot, delivery)
935            .await
936            .unwrap();
937
938        assert!(
939            claim_unqueued(tx.as_mut(), 10, None)
940                .await
941                .unwrap()
942                .is_empty()
943        );
944    }
945
946    #[tokio::test]
947    async fn a_slot_with_no_delivery_yet_reports_as_queued() {
948        insert_data!(:tx, :user, :org, :course);
949        let slot = claim_a_mail(tx.as_mut(), course).await;
950        let reports = get_send_status_reports(tx.as_mut(), &[slot]).await.unwrap();
951        assert_eq!(
952            reports.get(&slot).map(|report| report.email_send_status),
953            Some(EmailSendStatus::Queued)
954        );
955    }
956}
957
958/// A linking email whose link can still be used.
959#[derive(Debug, Clone)]
960pub struct UnusedLinkingLink {
961    pub id: Uuid,
962    pub course_id: Uuid,
963    pub course_name: String,
964    /// The course code of the email's course, where its modules agree on one.
965    pub uh_course_code: Option<String>,
966    pub emailed_to: DbSecret,
967    pub claimed_at: DateTime<Utc>,
968    pub expires_at: DateTime<Utc>,
969}
970
971/// Linking emails whose link can still be used, oldest first.
972pub async fn get_unused_links(
973    conn: &mut PgConnection,
974    limit: i64,
975) -> ModelResult<Vec<UnusedLinkingLink>> {
976    let res = sqlx::query_as!(
977        UnusedLinkingLink,
978        r#"
979SELECT e.id,
980  e.course_id,
981  c.name AS course_name,
982  (
983    SELECT MIN(TRIM(cm.uh_course_code))
984    FROM course_modules cm
985    WHERE cm.course_id = e.course_id
986      AND cm.deleted_at IS NULL
987      AND TRIM(COALESCE(cm.uh_course_code, '')) <> ''
988  ) AS "uh_course_code?",
989  e.emailed_to,
990  e.sent_at AS claimed_at,
991  t.expires_at
992FROM credit_registration_account_linking_emails e
993  JOIN courses c ON c.id = e.course_id AND c.deleted_at IS NULL
994  JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
995WHERE e.deleted_at IS NULL
996  AND e.replaced_at IS NULL
997  AND is_usable_verification_token(t)
998ORDER BY e.sent_at,
999  e.id
1000LIMIT $1
1001        "#,
1002        limit,
1003    )
1004    .fetch_all(conn)
1005    .await?;
1006    Ok(res)
1007}
1008
1009/// How many links can still be used per course code, over every course with a module on the code.
1010pub async fn count_unused_links_by_course_code(
1011    conn: &mut PgConnection,
1012) -> ModelResult<Vec<(String, i64)>> {
1013    let rows = sqlx::query!(
1014        r#"
1015SELECT TRIM(cm.uh_course_code) AS "course_code!",
1016  COUNT(DISTINCT e.id) AS "count!"
1017FROM credit_registration_account_linking_emails e
1018  JOIN courses c ON c.id = e.course_id AND c.deleted_at IS NULL
1019  JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
1020  JOIN course_modules cm ON cm.course_id = e.course_id
1021  AND cm.deleted_at IS NULL
1022  AND TRIM(COALESCE(cm.uh_course_code, '')) <> ''
1023WHERE e.deleted_at IS NULL
1024  AND e.replaced_at IS NULL
1025  AND is_usable_verification_token(t)
1026GROUP BY TRIM(cm.uh_course_code)
1027        "#,
1028    )
1029    .fetch_all(conn)
1030    .await?;
1031    Ok(rows
1032        .into_iter()
1033        .map(|row| (row.course_code, row.count))
1034        .collect())
1035}
1036
1037/// Which linking emails a [`get_page`] lists, by what became of their link and their send.
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
1039#[serde(rename_all = "snake_case")]
1040pub enum LinkingEmailListStatus {
1041    /// Carries a link that can still be used.
1042    Unused,
1043    Used,
1044    /// The link expired or was retired before anyone used it.
1045    Expired,
1046    /// Not handed to the mail server yet, or the send failed.
1047    NotSent,
1048}
1049
1050impl LinkingEmailListStatus {
1051    /// The status name the `get_page` query matches on.
1052    fn as_sql(self) -> &'static str {
1053        match self {
1054            Self::Unused => "unused",
1055            Self::Used => "used",
1056            Self::Expired => "expired",
1057            Self::NotSent => "not_sent",
1058        }
1059    }
1060}
1061
1062/// One linking email as the admin's searchable list shows it.
1063#[derive(Debug, Clone)]
1064pub struct LinkingEmailListRow {
1065    pub id: Uuid,
1066    pub course_id: Uuid,
1067    pub course_name: String,
1068    pub emailed_to: DbSecret,
1069    pub student_number: DbSecret,
1070    pub first_names: Option<DbSecret>,
1071    pub last_name: Option<DbSecret>,
1072    pub claimed_at: DateTime<Utc>,
1073    /// When the `link-emails` phase queued it for the email worker; `None` until then.
1074    pub queued_at: Option<DateTime<Utc>>,
1075    /// `None` until queued.
1076    pub send_status: Option<EmailSendStatusReport>,
1077    /// The newest delivery error's message, however transient.
1078    pub last_error_message: Option<String>,
1079    pub link_state: LinkingMailLinkState,
1080    /// `None` for a mail sent without a link.
1081    pub link_expires_at: Option<DateTime<Utc>>,
1082    pub link_used_at: Option<DateTime<Utc>>,
1083    pub claimed_by_user_id: Option<Uuid>,
1084    pub claimed_by_email: Option<String>,
1085    pub total_count: i64,
1086}
1087
1088/// A page of linking emails on live courses, newest first, including ones a later mail replaced.
1089///
1090/// `address` matches any part of the recipient address, ignoring case, and overrides `status`, so a
1091/// search for a student finds their mail whatever became of it.
1092pub async fn get_page(
1093    conn: &mut PgConnection,
1094    status: Option<LinkingEmailListStatus>,
1095    address: Option<&str>,
1096    limit: i64,
1097    offset: i64,
1098) -> ModelResult<Vec<LinkingEmailListRow>> {
1099    let address_pattern = address.map(|address| {
1100        format!(
1101            "%{}%",
1102            crate::library::students_view::escape_like_pattern(address)
1103        )
1104    });
1105    let rows = sqlx::query!(
1106        r#"
1107WITH page AS (
1108  SELECT e.id,
1109    e.sent_at,
1110    c.name AS course_name,
1111    COUNT(*) OVER () AS total_count
1112  FROM credit_registration_account_linking_emails e
1113    JOIN courses c ON c.id = e.course_id
1114    AND c.deleted_at IS NULL
1115    LEFT JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
1116    LEFT JOIN email_deliveries ed ON ed.id = e.email_delivery_id
1117    AND ed.deleted_at IS NULL
1118  WHERE e.deleted_at IS NULL
1119    AND (
1120      $2::text IS NULL
1121      OR e.emailed_to ILIKE $2 ESCAPE '\'
1122    )
1123    AND (
1124      $1::text IS NULL
1125      OR (
1126        $1 = 'unused'
1127        AND is_usable_verification_token(t)
1128      )
1129      OR (
1130        $1 = 'used'
1131        AND t.used_at IS NOT NULL
1132      )
1133      OR (
1134        $1 = 'expired'
1135        AND e.student_number_verification_token_id IS NOT NULL
1136        AND t.used_at IS NULL
1137        AND NOT is_usable_verification_token(t)
1138      )
1139      OR (
1140        $1 = 'not_sent'
1141        AND ed.sent IS DISTINCT FROM TRUE
1142      )
1143    )
1144  ORDER BY e.sent_at DESC,
1145    e.id
1146  LIMIT $3 OFFSET $4
1147)
1148SELECT e.id,
1149  e.course_id,
1150  page.course_name AS "course_name!",
1151  e.emailed_to,
1152  e.student_number,
1153  t.first_names AS "first_names?",
1154  t.last_name AS "last_name?",
1155  e.sent_at AS claimed_at,
1156  ed.created_at AS "queued_at?",
1157  ed.sent AS "delivery_sent?",
1158  ed.retryable AS "retryable?",
1159  ed.retry_count AS "retry_count?",
1160  ed.next_retry_at AS "next_retry_at?",
1161  ed.first_failed_at AS "first_failed_at?",
1162  ed.last_attempt_at AS "last_attempt_at?",
1163  latest_error.error_code AS "error_code?",
1164  latest_error.error_message AS "error_message?",
1165  latest_error.is_transient AS "is_transient?",
1166  (
1167    e.student_number_verification_token_id IS NULL
1168    OR is_usable_verification_token(t)
1169  ) AS "is_usable!",
1170  t.expires_at AS "link_expires_at?",
1171  t.used_at AS "link_used_at?",
1172  t.claimed_by_user_id AS "claimed_by_user_id?",
1173  claimer.email AS "claimed_by_email?",
1174  page.total_count AS "total_count!"
1175FROM page
1176  JOIN credit_registration_account_linking_emails e ON e.id = page.id
1177  LEFT JOIN student_number_verification_tokens t ON t.id = e.student_number_verification_token_id
1178  LEFT JOIN user_details claimer ON claimer.user_id = t.claimed_by_user_id
1179  LEFT JOIN email_deliveries ed ON ed.id = e.email_delivery_id
1180  AND ed.deleted_at IS NULL
1181  LEFT JOIN LATERAL (
1182    SELECT ede.error_code,
1183      ede.error_message,
1184      ede.is_transient
1185    FROM email_delivery_errors ede
1186    WHERE ede.email_delivery_id = ed.id
1187      AND ede.deleted_at IS NULL
1188    ORDER BY ede.attempt DESC,
1189      ede.created_at DESC
1190    LIMIT 1
1191  ) latest_error ON TRUE
1192ORDER BY page.sent_at DESC,
1193  page.id
1194        "#,
1195        status
1196            .filter(|_| address.is_none())
1197            .map(LinkingEmailListStatus::as_sql),
1198        address_pattern,
1199        limit,
1200        offset,
1201    )
1202    .fetch_all(conn)
1203    .await?;
1204    let now = Utc::now();
1205    Ok(rows
1206        .into_iter()
1207        .map(|row| {
1208            let send_status = row.delivery_sent.map(|sent| {
1209                derive_email_send_status(
1210                    &EmailSendStatusFacts {
1211                        sent,
1212                        retryable: row.retryable.unwrap_or(false),
1213                        retry_count: row.retry_count.unwrap_or(0),
1214                        next_retry_at: row.next_retry_at,
1215                        first_failed_at: row.first_failed_at,
1216                        last_attempt_at: row.last_attempt_at,
1217                        failure_code: row.error_code,
1218                        failure_is_transient: row.is_transient,
1219                    },
1220                    now,
1221                )
1222            });
1223            LinkingEmailListRow {
1224                id: row.id,
1225                course_id: row.course_id,
1226                course_name: row.course_name,
1227                emailed_to: row.emailed_to,
1228                student_number: row.student_number,
1229                first_names: row.first_names,
1230                last_name: row.last_name,
1231                claimed_at: row.claimed_at,
1232                queued_at: row.queued_at,
1233                send_status,
1234                last_error_message: row.error_message,
1235                link_state: LinkingMailLinkState::of(row.is_usable, row.link_used_at.is_some()),
1236                link_expires_at: row.link_expires_at,
1237                link_used_at: row.link_used_at,
1238                claimed_by_user_id: row.claimed_by_user_id,
1239                claimed_by_email: row.claimed_by_email,
1240                total_count: row.total_count,
1241            }
1242        })
1243        .collect())
1244}