Skip to main content

headless_lms_credit_registration/use_cases/
link_emails.rs

1//! The `link-emails` phase: turning a claimed mail slot into a queued message.
2//!
3//! It talks to no study registry, which is the point of it being its own phase: a wedged mail queue
4//! and an unreachable Sisu are different problems. The caps and the dedup guard applied when the
5//! slot was claimed, so this phase retries until the message is queued rather than deciding again.
6
7use headless_lms_models::credit_registration_account_linking_emails::{
8    LinkingMailToQueue, claim_unqueued, set_email_delivery_id,
9};
10use headless_lms_models::email_deliveries::insert_email_delivery_to_address;
11use headless_lms_models::email_templates::EmailTemplateType;
12use headless_lms_models::library::credit_registration::account_linking::link_student_number_url;
13use headless_lms_utils::secret_string::expose_option;
14use secrecy::ExposeSecret;
15use serde_json::json;
16use sqlx::{Connection, PgPool};
17
18use crate::error::CreditRegistrationResult;
19use crate::use_cases::mail_queue::{MailQueueSummary, TemplateCache, template_language};
20use crate::workflow::Counts;
21use headless_lms_models::credit_registrations::RegistrationScope;
22
23/// How many mails one iteration queues; the sender has its own rate, so this only bounds how much
24/// one transaction holds open.
25const QUEUE_LIMIT: i64 = 200;
26
27const TEMPLATE: EmailTemplateType = EmailTemplateType::CreditRegistrationAccountLinking;
28
29/// `base_url` is the absolute base for the mail's link, which outlives the process that wrote it.
30pub(crate) async fn run(
31    pool: &PgPool,
32    scope: &RegistrationScope,
33    base_url: &str,
34) -> CreditRegistrationResult<Counts> {
35    let mut conn = pool.acquire().await?;
36    let mut tx = conn.begin().await?;
37    let claimed = claim_unqueued(&mut tx, QUEUE_LIMIT, scope.course_id).await?;
38    let mut templates = TemplateCache::default();
39    let mut summary = MailQueueSummary::new(claimed.len());
40    for mail in &claimed {
41        let language = template_language(&mail.course_language_code);
42        let Some(template_id) = templates.id_for(&mut tx, TEMPLATE, &language).await? else {
43            summary.skip_missing_template(TEMPLATE, &language, language.as_str());
44            continue;
45        };
46        let delivery = insert_email_delivery_to_address(
47            &mut tx,
48            mail.emailed_to.expose_secret(),
49            template_id,
50            &placeholders(base_url, mail),
51        )
52        .await?;
53        set_email_delivery_id(&mut tx, mail.id, delivery).await?;
54    }
55    tx.commit().await?;
56    Ok(summary.finish("No credit_registration_account_linking email template for:"))
57}
58
59/// Stored on the delivery row because the recipient may have no account here for the sender to read
60/// them from.
61fn placeholders(base_url: &str, mail: &LinkingMailToQueue) -> serde_json::Value {
62    json!({
63        "LINK": link_student_number_url(base_url, mail.token.expose_secret()),
64        "NAME": expose_option(&mail.first_names).unwrap_or_default(),
65        "STUDENT_NUMBER": mail.student_number.expose_secret(),
66        "COURSE_NAME": mail.course_name,
67    })
68}