1use std::collections::HashMap;
9
10use crate::credit_registration_account_linking_emails::{
11 self, ExistingLinkingMailFact, NewAccountLinkingEmail, claim_send_slots,
12 get_existing_facts_for_persons,
13};
14use crate::credit_registration_admin_actions::{
15 CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget,
16 NewCreditRegistrationAdminAction,
17};
18use crate::prelude::*;
19use crate::student_number_verification_tokens::{
20 NewStudentNumberVerificationToken, insert_batch as insert_tokens_batch,
21};
22
23pub const LINK_STUDENT_NUMBER_PATH: &str = "/link-student-number";
25
26pub fn link_student_number_url(base_url: &str, token: &str) -> String {
28 format!(
29 "{}{LINK_STUDENT_NUMBER_PATH}/{token}",
30 base_url.trim_end_matches('/')
31 )
32}
33
34pub const LINKING_MAIL_QUIET_PERIOD_SECS: i64 = 24 * 60 * 60;
36
37pub const MAX_LINKING_MAILS_PER_PERSON_AND_COURSE: i64 = 3;
40
41#[derive(Debug, Clone, PartialEq)]
43pub struct DiscoveredPerson {
44 pub sisu_person_id: String,
45 pub student_number: String,
46 pub first_names: Option<String>,
47 pub last_name: Option<String>,
48 pub course_id: Uuid,
49 pub addresses: Vec<String>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub struct ClaimedLinkingMails {
56 pub claimed: i32,
57 pub suppressed_by_dedup: i32,
58 pub suppressed_by_rate_cap: i32,
59}
60
61pub async fn claim_linking_mails(
63 conn: &mut PgConnection,
64 person: &DiscoveredPerson,
65) -> ModelResult<ClaimedLinkingMails> {
66 Ok(
67 claim_linking_mails_batch(conn, std::slice::from_ref(person))
68 .await?
69 .into_iter()
70 .next()
71 .unwrap_or_default(),
72 )
73}
74
75pub async fn claim_linking_mails_batch(
79 conn: &mut PgConnection,
80 people: &[DiscoveredPerson],
81) -> ModelResult<Vec<ClaimedLinkingMails>> {
82 let mut outcomes = vec![ClaimedLinkingMails::default(); people.len()];
83 let per_person_addresses: Vec<Vec<String>> = people
84 .iter()
85 .map(|person| distinct_addresses(&person.addresses))
86 .collect();
87
88 let sisu_person_ids: Vec<String> = people
89 .iter()
90 .enumerate()
91 .filter(|(i, _)| !per_person_addresses[*i].is_empty())
92 .map(|(_, person)| person.sisu_person_id.clone())
93 .collect();
94 if sisu_person_ids.is_empty() {
95 return Ok(outcomes);
96 }
97 let facts = get_existing_facts_for_persons(conn, &sisu_person_ids).await?;
98 let mut by_person: HashMap<&str, Vec<&ExistingLinkingMailFact>> = HashMap::new();
99 for fact in &facts {
100 by_person
101 .entry(fact.sisu_person_id.as_str())
102 .or_default()
103 .push(fact);
104 }
105 let quiet_since = Utc::now() - chrono::Duration::seconds(LINKING_MAIL_QUIET_PERIOD_SECS);
106
107 let mut to_claim: Vec<(usize, String)> = Vec::new();
108 for (i, person) in people.iter().enumerate() {
109 if per_person_addresses[i].is_empty() {
110 continue;
111 }
112 let person_facts = by_person
113 .get(person.sisu_person_id.as_str())
114 .map(Vec::as_slice)
115 .unwrap_or(&[]);
116 let mut allowance = remaining_allowance(person, person_facts, quiet_since);
117 for address in &per_person_addresses[i] {
118 if already_mailed(person_facts, person.course_id, address) {
119 outcomes[i].suppressed_by_dedup += 1;
120 continue;
121 }
122 if allowance == 0 {
123 outcomes[i].suppressed_by_rate_cap += 1;
124 continue;
125 }
126 allowance -= 1;
127 to_claim.push((i, address.clone()));
128 }
129 }
130 if to_claim.is_empty() {
131 return Ok(outcomes);
132 }
133
134 let mut new_tokens = Vec::with_capacity(to_claim.len());
136 let mut new_slots = Vec::with_capacity(to_claim.len());
137 let mut token_ids = Vec::with_capacity(to_claim.len());
138 let mut token_owner: HashMap<Uuid, usize> = HashMap::new();
139 for (person_index, address) in &to_claim {
140 let person = &people[*person_index];
141 let token_id = Uuid::new_v4();
142 new_tokens.push(NewStudentNumberVerificationToken {
143 student_number: person.student_number.clone(),
144 sisu_person_id: person.sisu_person_id.clone(),
145 first_names: person.first_names.clone(),
146 last_name: person.last_name.clone(),
147 emailed_to: address.clone(),
148 course_id: Some(person.course_id),
149 });
150 token_owner.insert(token_id, *person_index);
151 token_ids.push(token_id);
152 new_slots.push(NewAccountLinkingEmail {
153 student_number: person.student_number.clone(),
154 sisu_person_id: person.sisu_person_id.clone(),
155 course_id: person.course_id,
156 emailed_to: address.clone(),
157 student_number_verification_token_id: Some(token_id),
158 email_delivery_id: None,
159 });
160 }
161 insert_tokens_batch(conn, &token_ids, &new_tokens).await?;
162
163 let claimed_token_ids = claim_send_slots(conn, &new_slots, &token_ids).await?;
164 let lost_token_ids: Vec<Uuid> = token_owner
165 .keys()
166 .filter(|id| !claimed_token_ids.contains(id))
167 .copied()
168 .collect();
169 void_tokens(conn, &lost_token_ids).await?;
171 for (token_id, person_index) in &token_owner {
172 if claimed_token_ids.contains(token_id) {
173 outcomes[*person_index].claimed += 1;
174 } else {
175 outcomes[*person_index].suppressed_by_dedup += 1;
177 }
178 }
179 Ok(outcomes)
180}
181
182pub async fn retire_capped_mails(
186 conn: &mut PgConnection,
187 actor_user_id: Uuid,
188 actor_role: &str,
189 course_id: Uuid,
190 student_number: &str,
191 reason: &str,
192) -> ModelResult<i64> {
193 let Some(person_id) = person_id_of_mails(conn, course_id, student_number).await? else {
194 return Ok(0);
195 };
196 let quiet_since = Utc::now() - chrono::Duration::seconds(LINKING_MAIL_QUIET_PERIOD_SECS);
197 let mails =
198 credit_registration_account_linking_emails::get_by_sisu_person_id(conn, &person_id).await?;
199 let retired: Vec<Uuid> = mails
202 .iter()
203 .filter(|mail| mail.course_id == course_id || mail.sent_at >= quiet_since)
204 .map(|mail| mail.id)
205 .collect();
206 if retired.is_empty() {
207 return Ok(0);
208 }
209
210 let mut tx = conn.begin().await?;
211 credit_registration_account_linking_emails::soft_delete_batch(&mut tx, &retired).await?;
212 crate::credit_registration_admin_actions::record(
213 &mut tx,
214 &NewCreditRegistrationAdminAction {
215 target_id: Some(course_id),
216 reason: Some(reason.to_string()),
217 details: Some(serde_json::json!({
218 "student_number": student_number,
219 "retired_linking_email_ids": retired,
220 })),
221 affected_row_count: Some(i32::try_from(retired.len()).unwrap_or(i32::MAX)),
222 ..NewCreditRegistrationAdminAction::new(
223 CreditRegistrationAdminAction::OverrideRateCap,
224 CreditRegistrationAdminActionTarget::Course,
225 actor_user_id,
226 actor_role,
227 )
228 },
229 )
230 .await?;
231 tx.commit().await?;
232 Ok(retired.len() as i64)
233}
234
235async fn person_id_of_mails(
236 conn: &mut PgConnection,
237 course_id: Uuid,
238 student_number: &str,
239) -> ModelResult<Option<String>> {
240 let mails = credit_registration_account_linking_emails::get_by_course_id_and_student_number(
241 conn,
242 course_id,
243 student_number,
244 )
245 .await?;
246 Ok(mails.into_iter().next().map(|mail| mail.sisu_person_id))
247}
248
249fn remaining_allowance(
251 person: &DiscoveredPerson,
252 facts: &[&ExistingLinkingMailFact],
253 quiet_since: DateTime<Utc>,
254) -> i64 {
255 if facts.iter().any(|fact| fact.sent_at >= quiet_since) {
257 return 0;
258 }
259 let already_sent = facts
260 .iter()
261 .filter(|fact| fact.course_id == person.course_id)
262 .count() as i64;
263 (MAX_LINKING_MAILS_PER_PERSON_AND_COURSE - already_sent).max(0)
264}
265
266fn already_mailed(facts: &[&ExistingLinkingMailFact], course_id: Uuid, address: &str) -> bool {
269 facts
270 .iter()
271 .any(|fact| fact.course_id == course_id && fact.emailed_to.eq_ignore_ascii_case(address))
272}
273
274async fn void_tokens(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<()> {
276 if ids.is_empty() {
277 return Ok(());
278 }
279 sqlx::query!(
280 r#"
281UPDATE student_number_verification_tokens
282SET deleted_at = now()
283WHERE id = ANY($1::uuid [])
284 AND deleted_at IS NULL
285 "#,
286 ids,
287 )
288 .execute(conn)
289 .await?;
290 Ok(())
291}
292
293fn distinct_addresses(addresses: &[String]) -> Vec<String> {
296 let mut kept: Vec<String> = Vec::new();
297 for address in addresses {
298 let trimmed = address.trim();
299 if trimmed.is_empty() {
300 continue;
301 }
302 if kept.iter().any(|seen| seen.eq_ignore_ascii_case(trimmed)) {
303 continue;
304 }
305 kept.push(trimmed.to_string());
306 }
307 kept
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::credit_registration_account_linking_emails::{
314 count_sent_for_person_and_course, get_by_sisu_person_id,
315 };
316 use crate::credit_registration_admin_actions::{self, GLOBAL_ADMIN_ROLE};
317 use crate::student_number_verification_tokens::{claim, get_by_ids};
318 use crate::test_helper::*;
319
320 fn person(course_id: Uuid, addresses: &[&str]) -> DiscoveredPerson {
321 DiscoveredPerson {
322 sisu_person_id: "hy-hlo-1".to_string(),
323 student_number: "012345678".to_string(),
324 first_names: Some("Aada Maria".to_string()),
325 last_name: Some("Virtanen".to_string()),
326 course_id,
327 addresses: addresses.iter().map(|a| a.to_string()).collect(),
328 }
329 }
330
331 #[tokio::test]
332 async fn each_address_of_a_person_gets_its_own_mail_and_token() {
333 insert_data!(:tx, :user, :org, :course);
334 let claimed = claim_linking_mails(
335 tx.as_mut(),
336 &person(course, &["aada@helsinki.fi", "aada@example.com"]),
337 )
338 .await
339 .unwrap();
340 assert_eq!(claimed.claimed, 2);
341 assert_eq!(claimed.suppressed_by_dedup, 0);
342 let rows = get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
343 .await
344 .unwrap();
345 assert_eq!(rows.len(), 2);
346 for row in rows {
347 assert!(row.student_number_verification_token_id.is_some());
348 assert!(row.email_delivery_id.is_none());
349 }
350 }
351
352 #[tokio::test]
353 async fn one_address_repeated_is_claimed_once() {
354 insert_data!(:tx, :user, :org, :course);
355 let claimed = claim_linking_mails(
356 tx.as_mut(),
357 &person(course, &["Aada@Helsinki.fi", "aada@helsinki.fi", " "]),
358 )
359 .await
360 .unwrap();
361 assert_eq!(claimed.claimed, 1);
362 assert_eq!(claimed.suppressed_by_rate_cap, 0);
363 }
364
365 #[tokio::test]
366 async fn mailing_the_same_address_twice_is_refused_as_a_duplicate() {
367 insert_data!(:tx, :user, :org, :course);
368 let discovered = person(course, &["aada@helsinki.fi"]);
369 assert_eq!(
370 claim_linking_mails(tx.as_mut(), &discovered).await.unwrap(),
371 ClaimedLinkingMails {
372 claimed: 1,
373 ..ClaimedLinkingMails::default()
374 }
375 );
376 assert_eq!(
377 claim_linking_mails(tx.as_mut(), &discovered).await.unwrap(),
378 ClaimedLinkingMails {
379 suppressed_by_dedup: 1,
380 ..ClaimedLinkingMails::default()
381 }
382 );
383 assert_eq!(
384 get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
385 .await
386 .unwrap()
387 .len(),
388 1
389 );
390 }
391
392 #[tokio::test]
393 async fn a_person_mailed_today_is_left_alone_even_at_another_address() {
394 insert_data!(:tx, :user, :org, :course);
395 claim_linking_mails(tx.as_mut(), &person(course, &["aada@helsinki.fi"]))
396 .await
397 .unwrap();
398 let claimed = claim_linking_mails(tx.as_mut(), &person(course, &["aada@example.com"]))
399 .await
400 .unwrap();
401 assert_eq!(
402 claimed,
403 ClaimedLinkingMails {
404 suppressed_by_rate_cap: 1,
405 ..ClaimedLinkingMails::default()
406 }
407 );
408 }
409
410 #[tokio::test]
411 async fn a_person_and_course_are_never_mailed_more_than_the_cap() {
412 insert_data!(:tx, :user, :org, :course);
413 let cap = usize::try_from(MAX_LINKING_MAILS_PER_PERSON_AND_COURSE).unwrap();
414 let addresses: Vec<String> = (0..cap + 2)
415 .map(|i| format!("aada{i}@example.com"))
416 .collect();
417 let claimed = claim_linking_mails(
418 tx.as_mut(),
419 &DiscoveredPerson {
420 addresses,
421 ..person(course, &[])
422 },
423 )
424 .await
425 .unwrap();
426 assert_eq!(
427 i64::from(claimed.claimed),
428 MAX_LINKING_MAILS_PER_PERSON_AND_COURSE
429 );
430 assert_eq!(claimed.suppressed_by_rate_cap, 2);
431 }
432
433 #[tokio::test]
434 async fn the_token_is_created_unbound_and_can_be_claimed_only_once() {
435 insert_data!(:tx, :user, :org, :course);
436 claim_linking_mails(tx.as_mut(), &person(course, &["aada@helsinki.fi"]))
437 .await
438 .unwrap();
439 let slot = get_by_sisu_person_id(tx.as_mut(), "hy-hlo-1")
440 .await
441 .unwrap()
442 .pop()
443 .expect("the claim wrote a slot");
444 let token_id = slot.student_number_verification_token_id.unwrap();
445 let token = get_by_ids(tx.as_mut(), &[token_id])
446 .await
447 .unwrap()
448 .remove(&token_id)
449 .expect("the claim minted a token");
450 assert_eq!(token.claimed_by_user_id, None);
451 assert_eq!(token.used_at, None);
452 assert!(token.expires_at > Utc::now());
453
454 assert!(claim(tx.as_mut(), &token.token, user).await.unwrap());
455 assert!(!claim(tx.as_mut(), &token.token, user).await.unwrap());
456 }
457
458 #[tokio::test]
459 async fn the_rate_cap_override_retires_the_ledger_rows_and_audits_itself() {
460 insert_data!(:tx, :user, :org, :course);
461
462 let claimed = claim_linking_mails(
463 tx.as_mut(),
464 &DiscoveredPerson {
465 sisu_person_id: "hy-hlo-1".to_string(),
466 student_number: "012345678".to_string(),
467 first_names: Some("Aada Maria".to_string()),
468 last_name: Some("Virtanen".to_string()),
469 course_id: course,
470 addresses: vec!["aada@helsinki.fi".to_string()],
471 },
472 )
473 .await
474 .unwrap();
475 assert_eq!(claimed.claimed, 1);
476 assert_eq!(
477 count_sent_for_person_and_course(tx.as_mut(), "hy-hlo-1", course)
478 .await
479 .unwrap(),
480 1
481 );
482
483 let retired = retire_capped_mails(
484 tx.as_mut(),
485 user,
486 GLOBAL_ADMIN_ROLE,
487 course,
488 "012345678",
489 "The recipient's mail host rejects everything we send.",
490 )
491 .await
492 .unwrap();
493 assert_eq!(retired, 1);
494 assert_eq!(
495 count_sent_for_person_and_course(tx.as_mut(), "hy-hlo-1", course)
496 .await
497 .unwrap(),
498 0
499 );
500
501 let actions = credit_registration_admin_actions::get_by_actor(tx.as_mut(), user, 10)
502 .await
503 .unwrap();
504 assert_eq!(actions.len(), 1);
505 let action = &actions[0];
506 assert_eq!(
507 action.action,
508 CreditRegistrationAdminAction::OverrideRateCap
509 );
510 assert_eq!(action.actor_role, GLOBAL_ADMIN_ROLE);
511 assert_eq!(
512 action.reason.as_deref(),
513 Some("The recipient's mail host rejects everything we send.")
514 );
515 assert_eq!(action.affected_row_count, Some(1));
516 }
517
518 #[tokio::test]
519 async fn an_override_with_nothing_to_retire_writes_nothing() {
520 insert_data!(:tx, :user, :org, :course);
521
522 let retired = retire_capped_mails(
523 tx.as_mut(),
524 user,
525 GLOBAL_ADMIN_ROLE,
526 course,
527 "012345678",
528 "No mails yet.",
529 )
530 .await
531 .unwrap();
532 assert_eq!(retired, 0);
533 assert!(
534 credit_registration_admin_actions::get_by_actor(tx.as_mut(), user, 10)
535 .await
536 .unwrap()
537 .is_empty()
538 );
539 }
540}