cargagep-v2/src/services/mail/mails/mod.rs
Antoine Pelletier 51df9e75ad wip
2026-08-25 10:38:05 +02:00

165 lines
5 KiB
Rust

//! One file per mail the app sends.
//!
//! Adding one means adding a module here and calling it — the wording never
//! leaks into the rest of the app, and changing a mail is a change to one file
//! with no logic in it. What every mail shares — the block of facts, the
//! signature, the frame — lives here so the six of them cannot drift apart.
pub mod reservation_approved;
pub mod reservation_bikes_changed;
pub mod reservation_cancelled;
pub mod reservation_period_changed;
pub mod reservation_shared;
pub mod reservation_updated;
use chrono::{DateTime, Utc};
use crate::core::models::reservation::Reservation;
/// Who to ask about anything the mail does not answer
const BOT_URL: &str = "https://t.me/Logistique_Agepoly_Bot";
const BOT_HANDLE: &str = "@Logistique_Agepoly_Bot";
/// The facts every mail about a reservation shows.
///
/// Built from the reservation as stored plus the names of the bikes it holds:
/// a mail reads nothing itself, so writing one is only a matter of words.
#[derive(Debug, Clone)]
pub struct ReservationSummary {
pub id: i32,
pub unit: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub bikes: Vec<String>,
}
impl ReservationSummary {
pub fn new(reservation: &Reservation, bikes: Vec<String>) -> Self {
ReservationSummary {
id: reservation.id,
unit: reservation.unit.label().to_owned(),
start_time: reservation.start_time,
end_time: reservation.end_time,
bikes,
}
}
pub fn period(&self) -> String {
format!(
"du {} au {}",
format_moment(self.start_time),
format_moment(self.end_time)
)
}
pub fn bikes(&self) -> String {
if self.bikes.is_empty() {
"—".to_owned()
} else {
self.bikes.join(", ")
}
}
/// The three lines every mail repeats, so the reader never has to look the
/// reservation up to know which one is being talked about
pub fn html_details(&self) -> String {
format!(
"<ul>\
<li><strong>Association :</strong> {unit}</li>\
<li><strong>Période :</strong> {period}</li>\
<li><strong>Cargobikes réservés :</strong> {bikes}</li>\
</ul>",
unit = escape(&self.unit),
period = escape(&self.period()),
bikes = escape(&self.bikes()),
)
}
pub fn text_details(&self) -> String {
format!(
"- Association : {}\n- Période : {}\n- Cargobikes réservés : {}",
self.unit,
self.period(),
self.bikes()
)
}
}
/// The closing every mail ends on
pub fn signature_html() -> String {
format!(
"<p>Pour toute question, n'hésitez pas à contacter le bot logistique \
<a href=\"{BOT_URL}\">{BOT_HANDLE}</a>.</p>\
<p>Cordialement,<br>L'équipe AGEPoly.</p>"
)
}
pub fn signature_text() -> String {
format!(
"Pour toute question, n'hésitez pas à contacter le bot logistique {BOT_HANDLE} \
({BOT_URL}).\n\nCordialement,\nL'équipe AGEPoly."
)
}
/// The four characters an HTML body reads as markup
pub fn escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// Swiss local time, in the form the mails use
pub fn format_moment(moment: DateTime<Utc>) -> String {
moment
.with_timezone(&chrono::FixedOffset::east_opt(2 * 3600).expect("valid offset"))
.format("%d/%m/%Y %H:%M")
.to_string()
}
/// The plain frame every mail is poured into: a readable column, the system
/// font, and nothing a mail client has to fetch.
pub fn wrap(body: &str) -> String {
format!(
"<div style=\"font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; \
font-size: 15px; line-height: 1.5; color: #1a1a1a; max-width: 40rem;\">{body}</div>"
)
}
#[cfg(test)]
pub mod tests {
use chrono::TimeZone;
use super::*;
/// The fixture every mail test renders
pub fn summary() -> ReservationSummary {
ReservationSummary {
id: 65,
unit: "AGEPoly 2".to_owned(),
start_time: Utc.with_ymd_and_hms(2026, 8, 18, 22, 0, 0).unwrap(),
end_time: Utc.with_ymd_and_hms(2026, 8, 24, 22, 0, 0).unwrap(),
bikes: vec!["3000".to_owned()],
}
}
#[test]
fn the_period_is_written_in_local_time() {
// 22:00 UTC is midnight the next day in Lausanne
assert_eq!(
summary().period(),
"du 19/08/2026 00:00 au 25/08/2026 00:00"
);
}
#[test]
fn markup_in_a_unit_name_is_escaped() {
let mut summary = summary();
summary.unit = "<b>Fake</b> & Co".to_owned();
let details = summary.html_details();
assert!(details.contains("&lt;b&gt;Fake&lt;/b&gt; &amp; Co"));
assert!(details.contains("<strong>Association :</strong>"));
}
}