torrust_index/
mailer.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use jsonwebtoken::{encode, EncodingKey, Header};
5use lazy_static::lazy_static;
6use lettre::message::{MessageBuilder, MultiPart, SinglePart};
7use lettre::transport::smtp::authentication::{Credentials, Mechanism};
8use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
9use serde::{Deserialize, Serialize};
10use serde_json::value::{to_value, Value};
11use tera::{try_get_value, Context, Tera};
12
13use crate::config::Configuration;
14use crate::errors::ServiceError;
15use crate::utils::clock;
16use crate::web::api::server::v1::routes::API_VERSION_URL_PREFIX;
17
18lazy_static! {
19    pub static ref TEMPLATES: Tera = {
20        let mut tera = Tera::default();
21
22        match tera.add_template_file("templates/verify.html", Some("html_verify_email")) {
23            Ok(()) => {}
24            Err(e) => {
25                println!("Parsing error(s): {e}");
26                ::std::process::exit(1);
27            }
28        };
29
30        tera.autoescape_on(vec![".html", ".sql"]);
31        tera.register_filter("do_nothing", do_nothing_filter);
32        tera
33    };
34}
35
36/// This function is a dummy filter for tera.
37///
38/// # Panics
39///
40/// Panics if unable to convert values.
41///
42/// # Errors
43///
44/// This function will return an error if...
45#[allow(clippy::implicit_hasher)]
46pub fn do_nothing_filter(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
47    let s = try_get_value!("do_nothing_filter", "value", String, value);
48    Ok(to_value(s).unwrap())
49}
50
51pub struct Service {
52    cfg: Arc<Configuration>,
53    mailer: Arc<Mailer>,
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57pub struct VerifyClaims {
58    pub iss: String,
59    pub sub: i64,
60    pub exp: u64,
61}
62
63impl Service {
64    pub async fn new(cfg: Arc<Configuration>) -> Service {
65        let mailer = Arc::new(Self::get_mailer(&cfg).await);
66
67        Self { cfg, mailer }
68    }
69
70    async fn get_mailer(cfg: &Configuration) -> Mailer {
71        let settings = cfg.settings.read().await;
72
73        if !settings.mail.smtp.credentials.username.is_empty() && !settings.mail.smtp.credentials.password.is_empty() {
74            // SMTP authentication
75            let creds = Credentials::new(
76                settings.mail.smtp.credentials.username.clone(),
77                settings.mail.smtp.credentials.password.clone(),
78            );
79
80            AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&settings.mail.smtp.server)
81                .port(settings.mail.smtp.port)
82                .credentials(creds)
83                .authentication(vec![Mechanism::Login, Mechanism::Xoauth2, Mechanism::Plain])
84                .build()
85        } else {
86            // SMTP without authentication
87            AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&settings.mail.smtp.server)
88                .port(settings.mail.smtp.port)
89                .build()
90        }
91    }
92
93    /// Send Verification Email.
94    ///
95    /// # Errors
96    ///
97    /// This function will return an error if unable to send an email.
98    ///
99    /// # Panics
100    ///
101    /// This function will panic if the multipart builder had an error.
102    pub async fn send_verification_mail(
103        &self,
104        to: &str,
105        username: &str,
106        user_id: i64,
107        base_url: &str,
108    ) -> Result<(), ServiceError> {
109        let builder = self.get_builder(to).await;
110        let verification_url = self.get_verification_url(user_id, base_url).await;
111
112        let mail = build_letter(verification_url.as_str(), username, builder)?;
113
114        match self.mailer.send(mail).await {
115            Ok(_res) => Ok(()),
116            Err(e) => {
117                eprintln!("Failed to send email: {e}");
118                Err(ServiceError::FailedToSendVerificationEmail)
119            }
120        }
121    }
122
123    async fn get_builder(&self, to: &str) -> MessageBuilder {
124        let settings = self.cfg.settings.read().await;
125
126        Message::builder()
127            .from(settings.mail.from.clone())
128            .reply_to(settings.mail.reply_to.clone())
129            .to(to.parse().unwrap())
130    }
131
132    async fn get_verification_url(&self, user_id: i64, base_url: &str) -> String {
133        let settings = self.cfg.settings.read().await;
134
135        // create verification JWT
136        let key = settings.auth.user_claim_token_pepper.as_bytes();
137
138        // Create non expiring token that is only valid for email-verification
139        let claims = VerifyClaims {
140            iss: String::from("email-verification"),
141            sub: user_id,
142            exp: clock::now() + 315_569_260, // 10 years from now
143        };
144
145        let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap();
146
147        let base_url = match &settings.net.base_url {
148            Some(url) => url.to_string(),
149            None => base_url.to_string(),
150        };
151
152        format!("{base_url}/{API_VERSION_URL_PREFIX}/user/email/verify/{token}")
153    }
154}
155
156fn build_letter(verification_url: &str, username: &str, builder: MessageBuilder) -> Result<Message, ServiceError> {
157    let (plain_body, html_body) = build_content(verification_url, username).map_err(|e| {
158        tracing::error!("{e}");
159        ServiceError::InternalServerError
160    })?;
161
162    Ok(builder
163        .subject("Torrust - Email verification")
164        .multipart(
165            MultiPart::alternative()
166                .singlepart(
167                    SinglePart::builder()
168                        .header(lettre::message::header::ContentType::TEXT_PLAIN)
169                        .body(plain_body),
170                )
171                .singlepart(
172                    SinglePart::builder()
173                        .header(lettre::message::header::ContentType::TEXT_HTML)
174                        .body(html_body),
175                ),
176        )
177        .expect("the `multipart` builder had an error"))
178}
179
180fn build_content(verification_url: &str, username: &str) -> Result<(String, String), tera::Error> {
181    let plain_body = format!(
182        r#"
183                Welcome to Torrust, {username}!
184
185                Please click the confirmation link below to verify your account.
186                {verification_url}
187
188                If this account wasn't made by you, you can ignore this email.
189            "#
190    );
191    let mut context = Context::new();
192    context.insert("verification", &verification_url);
193    context.insert("username", &username);
194    let html_body = TEMPLATES.render("html_verify_email", &context)?;
195    Ok((plain_body, html_body))
196}
197
198pub type Mailer = AsyncSmtpTransport<Tokio1Executor>;
199
200#[cfg(test)]
201mod tests {
202    use lettre::Message;
203
204    use super::{build_content, build_letter};
205
206    #[test]
207    fn it_should_build_a_letter() {
208        let builder = Message::builder()
209            .from("from@a.b.c".parse().unwrap())
210            .reply_to("reply@a.b.c".parse().unwrap())
211            .to("to@a.b.c".parse().unwrap());
212
213        let _letter = build_letter("https://a.b.c/", "user", builder).unwrap();
214    }
215
216    #[test]
217    fn it_should_build_content() {
218        let (plain_body, html_body) = build_content("https://a.b.c/", "user").unwrap();
219        assert_ne!(plain_body, "");
220        assert_ne!(html_body, "");
221    }
222}