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