Skip to main content

revolt_database/util/
email.rs

1use std::{collections::HashSet, sync::LazyLock};
2
3use lettre::{
4    transport::smtp::{authentication::Credentials, client::Tls},
5    SmtpTransport,
6};
7use regex::Regex;
8use revolt_config::{config, ApiSmtp};
9use revolt_result::Result;
10
11static SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new("([^@]+)(@.+)").unwrap());
12static SYMBOL_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new("\\+.+|\\.").unwrap());
13static HANDLEBARS: LazyLock<handlebars::Handlebars<'static>> =
14    LazyLock::new(handlebars::Handlebars::new);
15static REVOLT_SOURCE_LIST: LazyLock<HashSet<String>> = LazyLock::new(|| {
16    include_str!("../../assets/revolt_source_list.txt")
17        .split('\n')
18        .map(|x| x.into())
19        .collect()
20});
21
22/// Strip special characters and aliases from emails
23pub fn normalise_email(original: String) -> String {
24    let split = SPLIT.captures(&original).unwrap();
25    let mut clean = SYMBOL_RE
26        .replace_all(split.get(1).unwrap().as_str(), "")
27        .to_string();
28
29    clean.push_str(split.get(2).unwrap().as_str());
30    clean.to_lowercase()
31}
32
33/// Email template
34#[derive(Clone)]
35pub struct Template {
36    /// Title of the email
37    pub title: String,
38    /// Plain text version of this email
39    pub text: String,
40    /// HTML version of this email
41    pub html: Option<String>,
42    /// URL to redirect people to from the email
43    ///
44    /// Use `{{url}}` to fill this field.
45    ///
46    /// Any given URL will be suffixed with a unique token if applicable.
47    ///
48    /// e.g. `https://example.com?t=` becomes `https://example.com?t=UNIQUE_CODE`
49    pub url: String,
50}
51
52/// Email templates
53#[derive(Clone)]
54pub struct Templates {
55    /// Template for email verification
56    pub verify: Template,
57    /// Template for password reset
58    pub reset: Template,
59    /// Template for password reset when the account already exists on creation
60    pub reset_existing: Template,
61    /// Template for account deletion
62    pub deletion: Template,
63    /// Template for suspention
64    pub suspension: Template,
65}
66
67pub async fn email_templates() -> Templates {
68    let config = config().await;
69
70    if std::env::var("TEST_DB").is_ok() {
71        Templates {
72            verify: Template {
73                title: "verify".into(),
74                text: "[[{{url}}]]".into(),
75                url: "".into(),
76                html: None,
77            },
78            reset: Template {
79                title: "reset".into(),
80                text: "[[{{url}}]]".into(),
81                url: "".into(),
82                html: None,
83            },
84            reset_existing: Template {
85                title: "reset_existing".into(),
86                text: "[[{{url}}]]".into(),
87                url: "".into(),
88                html: None,
89            },
90            deletion: Template {
91                title: "deletion".into(),
92                text: "[[{{url}}]]".into(),
93                url: "".into(),
94                html: None,
95            },
96            suspension: Template {
97                title: "suspension".into(),
98                text: "[[dummy]]".into(),
99                url: "".into(),
100                html: None,
101            },
102        }
103    } else if config.production {
104        Templates {
105            verify: Template {
106                title: "Verify your Stoat account.".into(),
107                text: include_str!("../../templates/verify.txt").into(),
108                url: format!("{}/login/verify/", config.hosts.app),
109                html: Some(include_str!("../../templates/verify.html").into()),
110            },
111            reset: Template {
112                title: "Reset your Stoat password.".into(),
113                text: include_str!("../../templates/reset.txt").into(),
114                url: format!("{}/login/reset/", config.hosts.app),
115                html: Some(include_str!("../../templates/reset.html").into()),
116            },
117            reset_existing: Template {
118                title: "You already have a Stoat account, reset your password.".into(),
119                text: include_str!("../../templates/reset-existing.txt").into(),
120                url: format!("{}/login/reset/", config.hosts.app),
121                html: Some(include_str!("../../templates/reset-existing.html").into()),
122            },
123            deletion: Template {
124                title: "Confirm account deletion.".into(),
125                text: include_str!("../../templates/deletion.txt").into(),
126                url: format!("{}/delete/", config.hosts.app),
127                html: Some(include_str!("../../templates/deletion.html").into()),
128            },
129            suspension: Template {
130                title: "Account Suspension".to_string(),
131                html: Some(include_str!("../../templates/suspension.html").to_owned()),
132                text: include_str!("../../templates/suspension.txt").to_owned(),
133                url: Default::default(),
134            },
135        }
136    } else {
137        Templates {
138            verify: Template {
139                title: "Verify your account.".into(),
140                text: include_str!("../../templates/verify.whitelabel.txt").into(),
141                url: format!("{}/login/verify/", config.hosts.app),
142                html: None,
143            },
144            reset: Template {
145                title: "Reset your password.".into(),
146                text: include_str!("../../templates/reset.whitelabel.txt").into(),
147                url: format!("{}/login/reset/", config.hosts.app),
148                html: None,
149            },
150            reset_existing: Template {
151                title: "Reset your password.".into(),
152                text: include_str!("../../templates/reset.whitelabel.txt").into(),
153                url: format!("{}/login/reset/", config.hosts.app),
154                html: None,
155            },
156            deletion: Template {
157                title: "Confirm account deletion.".into(),
158                text: include_str!("../../templates/deletion.whitelabel.txt").into(),
159                url: format!("{}/delete/", config.hosts.app),
160                html: None,
161            },
162            suspension: Template {
163                title: "Account Suspension".to_string(),
164                text: include_str!("../../templates/suspension.whitelabel.txt").to_owned(),
165                url: Default::default(),
166                html: None,
167            },
168        }
169    }
170}
171
172/// Create SMTP transport
173pub fn create_transport(smtp: &ApiSmtp) -> SmtpTransport {
174    let relay = if smtp.use_starttls == Some(true) {
175        SmtpTransport::starttls_relay(&smtp.host).unwrap()
176    } else {
177        SmtpTransport::relay(&smtp.host).unwrap()
178    };
179
180    let relay = if let Some(port) = smtp.port {
181        relay.port(port.try_into().unwrap())
182    } else {
183        relay
184    };
185
186    let relay = if smtp.use_tls == Some(false) {
187        relay.tls(Tls::None)
188    } else {
189        relay
190    };
191
192    relay
193        .credentials(Credentials::new(
194            smtp.username.clone(),
195            smtp.password.clone(),
196        ))
197        .build()
198}
199
200/// Render an email template
201fn render_template(text: &str, variables: &handlebars::JsonValue) -> Result<String> {
202    HANDLEBARS
203        .render_template(text, variables)
204        .map_err(|_| create_error!(RenderFail))
205}
206
207/// Send an email
208pub fn send_email(
209    smtp: &ApiSmtp,
210    address: String,
211    template: &Template,
212    variables: handlebars::JsonValue,
213) -> Result<()> {
214    let m = lettre::Message::builder()
215        .from(smtp.from_address.parse().expect("valid `smtp_from`"))
216        .to(address.parse().expect("valid `smtp_to`"))
217        .subject(template.title.clone());
218
219    let m = if let Some(reply_to) = &smtp.reply_to {
220        m.reply_to(reply_to.parse().expect("valid `smtp_reply_to`"))
221    } else {
222        m
223    };
224
225    let text = render_template(&template.text, &variables).expect("valid `template`");
226
227    let m = if let Some(html) = &template.html {
228        m.multipart(lettre::message::MultiPart::alternative_plain_html(
229            text,
230            render_template(html, &variables).expect("valid `template`"),
231        ))
232    } else {
233        m.body(text)
234    }
235    .expect("valid `message`");
236
237    use lettre::Transport;
238    let sender = create_transport(smtp);
239
240    match sender.send(&m) {
241        Ok(_) => Ok(()),
242        Err(error) => {
243            error!(
244                "Failed to send email to {}!\nlettre error: {}",
245                address, error
246            );
247
248            revolt_config::capture_error(&error);
249
250            Err(create_error!(EmailFailed))
251        }
252    }
253}
254
255pub fn validate_email(email: &str) -> Result<()> {
256    // Make sure this is an actual email
257    if !validator::validate_email(email) {
258        return Err(create_error!(IncorrectData {
259            with: "email".to_string()
260        }));
261    }
262
263    // Check if the email is blacklisted
264    if let Some(domain) = email.split('@').next_back() {
265        if REVOLT_SOURCE_LIST.contains(&domain.to_string()) {
266            return Err(create_error!(Blacklisted));
267        }
268    }
269
270    Ok(())
271}