scufflecloud_core_emails/
lib.rs

1//! Core email template rendering.
2//!
3//! ## License
4//!
5//! This project is licensed under the [AGPL-3.0](./LICENSE.AGPL-3.0).
6//!
7//! `SPDX-License-Identifier: AGPL-3.0`
8#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
9#![cfg_attr(docsrs, feature(doc_auto_cfg))]
10// #![deny(missing_docs)]
11#![deny(unsafe_code)]
12#![deny(unreachable_pub)]
13#![deny(clippy::mod_module_files)]
14
15use chrono::Datelike;
16use sailfish::{TemplateOnce, TemplateSimple};
17
18pub struct Email {
19    pub subject: String,
20    pub text: String,
21    pub html: String,
22}
23
24#[derive(sailfish::TemplateSimple)]
25#[template(path = "emails/register_with_email/subject.stpl")]
26struct RegisterWithEmailSubjectTemplate;
27
28#[derive(sailfish::Template)]
29#[template(path = "emails/register_with_email/text.stpl")]
30struct RegisterWithEmailTextTemplate {
31    url: String,
32    timeout_minutes: u32,
33    copyright_year: u32,
34}
35
36#[derive(sailfish::Template)]
37#[template(path = "emails/register_with_email/html.stpl")]
38struct RegisterWithEmailHtmlTemplate {
39    url: String,
40    timeout_minutes: u32,
41    copyright_year: u32,
42}
43
44pub fn register_with_email_email(
45    dashboard_origin: &url::Url,
46    code: String,
47    timeout: std::time::Duration,
48) -> Result<Email, sailfish::RenderError> {
49    let url = dashboard_origin
50        .join(&format!("/register/confirm?code={code}"))
51        .unwrap()
52        .to_string();
53
54    let timeout_minutes = timeout.as_secs() as u32 / 60;
55    let copyright_year = chrono::Utc::now().year() as u32;
56
57    let subject = RegisterWithEmailSubjectTemplate.render_once()?;
58    let text = RegisterWithEmailTextTemplate {
59        url: url.clone(),
60        timeout_minutes,
61        copyright_year,
62    }
63    .render_once()?;
64    let html = RegisterWithEmailHtmlTemplate {
65        url,
66        timeout_minutes,
67        copyright_year,
68    }
69    .render_once()?;
70
71    Ok(Email { subject, text, html })
72}
73
74#[derive(sailfish::TemplateSimple)]
75#[template(path = "emails/add_new_email/subject.stpl")]
76struct AddNewEmailSubjectTemplate;
77
78#[derive(sailfish::Template)]
79#[template(path = "emails/add_new_email/text.stpl")]
80struct AddNewEmailTextTemplate {
81    url: String,
82    timeout_minutes: u32,
83    copyright_year: u32,
84}
85
86#[derive(sailfish::Template)]
87#[template(path = "emails/add_new_email/html.stpl")]
88struct AddNewEmailHtmlTemplate {
89    url: String,
90    timeout_minutes: u32,
91    copyright_year: u32,
92}
93
94pub fn add_new_email_email(
95    dashboard_origin: &url::Url,
96    code: String,
97    timeout: std::time::Duration,
98) -> Result<Email, sailfish::RenderError> {
99    let url = dashboard_origin
100        .join(&format!("/settings/emails/confirm?code={code}"))
101        .unwrap()
102        .to_string();
103    let timeout_minutes = timeout.as_secs() as u32 / 60;
104    let copyright_year = chrono::Utc::now().year() as u32;
105
106    let subject = AddNewEmailSubjectTemplate.render_once()?;
107    let text = AddNewEmailTextTemplate {
108        url: url.clone(),
109        timeout_minutes,
110        copyright_year,
111    }
112    .render_once()?;
113    let html = AddNewEmailHtmlTemplate {
114        url,
115        timeout_minutes,
116        copyright_year,
117    }
118    .render_once()?;
119
120    Ok(Email { subject, text, html })
121}
122
123#[derive(sailfish::TemplateSimple)]
124#[template(path = "emails/magic_link/subject.stpl")]
125struct MagicLinkSubjectTemplate;
126
127#[derive(sailfish::Template)]
128#[template(path = "emails/magic_link/text.stpl")]
129struct MagicLinkTextTemplate {
130    url: String,
131    timeout_minutes: u32,
132    copyright_year: u32,
133}
134
135#[derive(sailfish::Template)]
136#[template(path = "emails/magic_link/html.stpl")]
137struct MagicLinkHtmlTemplate {
138    url: String,
139    timeout_minutes: u32,
140    copyright_year: u32,
141}
142
143pub fn magic_link_email(
144    dashboard_origin: &url::Url,
145    code: String,
146    timeout: std::time::Duration,
147) -> Result<Email, sailfish::RenderError> {
148    let url = dashboard_origin
149        .join(&format!("/login/magic-link?code={code}"))
150        .unwrap()
151        .to_string();
152    let timeout_minutes = timeout.as_secs() as u32 / 60;
153    let copyright_year = chrono::Utc::now().year() as u32;
154
155    let subject = MagicLinkSubjectTemplate.render_once()?;
156    let text = MagicLinkTextTemplate {
157        url: url.clone(),
158        timeout_minutes,
159        copyright_year,
160    }
161    .render_once()?;
162    let html = MagicLinkHtmlTemplate {
163        url,
164        timeout_minutes,
165        copyright_year,
166    }
167    .render_once()?;
168
169    Ok(Email { subject, text, html })
170}
171
172#[derive(sailfish::TemplateSimple)]
173#[template(path = "emails/new_device/subject.stpl")]
174struct NewDeviceSubjectTemplate;
175
176#[derive(Clone, Debug, Default)]
177pub struct GeoInfo {
178    pub city: Option<String>,
179    pub country: Option<String>,
180}
181
182impl GeoInfo {
183    fn is_empty(&self) -> bool {
184        self.city.is_none() && self.country.is_none()
185    }
186}
187
188impl From<maxminddb::geoip2::City<'_>> for GeoInfo {
189    fn from(value: maxminddb::geoip2::City) -> GeoInfo {
190        let city = value
191            .city
192            .and_then(|c| c.names)
193            .and_then(|names| names.get("en").map(|s| s.to_string()));
194        let country = value
195            .country
196            .and_then(|c| c.names)
197            .and_then(|names| names.get("en").map(|s| s.to_string()));
198
199        GeoInfo { city, country }
200    }
201}
202
203#[derive(sailfish::Template)]
204#[template(path = "emails/new_device/text.stpl")]
205struct NewDeviceTextTemplate {
206    activity_url: String,
207    ip_address: String,
208    geo_info: GeoInfo,
209    copyright_year: u32,
210}
211
212#[derive(sailfish::Template)]
213#[template(path = "emails/new_device/html.stpl")]
214struct NewDeviceHtmlTemplate {
215    activity_url: String,
216    ip_address: String,
217    geo_info: GeoInfo,
218    copyright_year: u32,
219}
220
221pub fn new_device_email(
222    dashboard_origin: &url::Url,
223    ip_addr: std::net::IpAddr,
224    geo_info: GeoInfo,
225) -> Result<Email, sailfish::RenderError> {
226    let ip_address = ip_addr.to_string();
227    // TODO: replace with actual link
228    let activity_url = dashboard_origin.join("/activity").unwrap().to_string();
229    let copyright_year = chrono::Utc::now().year() as u32;
230
231    let subject = NewDeviceSubjectTemplate.render_once()?;
232
233    let text = NewDeviceTextTemplate {
234        ip_address: ip_address.clone(),
235        geo_info: geo_info.clone(),
236        activity_url: activity_url.clone(),
237        copyright_year,
238    }
239    .render_once()?;
240
241    let html = NewDeviceHtmlTemplate {
242        ip_address,
243        geo_info,
244        activity_url,
245        copyright_year,
246    }
247    .render_once()?;
248
249    Ok(Email { subject, text, html })
250}