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
22pub 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#[derive(Clone)]
35pub struct Template {
36 pub title: String,
38 pub text: String,
40 pub html: Option<String>,
42 pub url: String,
50}
51
52#[derive(Clone)]
54pub struct Templates {
55 pub verify: Template,
57 pub reset: Template,
59 pub reset_existing: Template,
61 pub deletion: Template,
63 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
172pub 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
200fn render_template(text: &str, variables: &handlebars::JsonValue) -> Result<String> {
202 HANDLEBARS
203 .render_template(text, variables)
204 .map_err(|_| create_error!(RenderFail))
205}
206
207pub 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 if !validator::validate_email(email) {
258 return Err(create_error!(IncorrectData {
259 with: "email".to_string()
260 }));
261 }
262
263 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}