//! 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, pub end_time: DateTime, pub bikes: Vec, } impl ReservationSummary { pub fn new(reservation: &Reservation, bikes: Vec) -> 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!( "
    \
  • Association : {unit}
  • \
  • Période : {period}
  • \
  • Cargobikes réservés : {bikes}
  • \
", 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!( "

Pour toute question, n'hésitez pas à contacter le bot logistique \ {BOT_HANDLE}.

\

Cordialement,
L'équipe AGEPoly.

" ) } 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('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } /// Swiss local time, in the form the mails use pub fn format_moment(moment: DateTime) -> 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!( "
{body}
" ) } #[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 = "Fake & Co".to_owned(); let details = summary.html_details(); assert!(details.contains("<b>Fake</b> & Co")); assert!(details.contains("Association :")); } }