From feb9f16bc9f14a0034d3a930e90f15301de60dbc Mon Sep 17 00:00:00 2001 From: Philippe Loctaux Date: Sat, 22 Apr 2023 01:27:24 +0200 Subject: [PATCH] forgot password: send emails --- Cargo.lock | 269 +++++++++++++++++- Dockerfile | 1 + crates/email/Cargo.toml | 24 ++ crates/email/readme.md | 36 +++ crates/email/src/lib.rs | 151 ++++++++++ .../email/templates/password-reset.mjml.tera | 50 ++++ crates/ezidam/Cargo.toml | 1 + crates/ezidam/Rocket.toml | 6 + crates/ezidam/src/main.rs | 28 +- .../ezidam/src/routes/root/forgot_password.rs | 128 ++++++++- 10 files changed, 676 insertions(+), 18 deletions(-) create mode 100644 crates/email/Cargo.toml create mode 100644 crates/email/readme.md create mode 100644 crates/email/src/lib.rs create mode 100644 crates/email/templates/password-reset.mjml.tera diff --git a/Cargo.lock b/Cargo.lock index d2d680d..2abe08f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "ahash" version = "0.7.6" @@ -447,8 +457,18 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", ] [[package]] @@ -465,13 +485,38 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" dependencies = [ - "darling_core", + "darling_core 0.13.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", "quote", "syn 1.0.109", ] @@ -608,6 +653,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email" +version = "0.0.0" +dependencies = [ + "lettre", + "mrml", + "serde", + "tera", + "thiserror", +] + +[[package]] +name = "email-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75" +dependencies = [ + "base64 0.21.0", + "memchr", +] + [[package]] name = "email_address" version = "0.2.4" @@ -668,6 +734,7 @@ dependencies = [ "base64 0.21.0", "chrono-tz 0.8.2", "database_pool", + "email", "email_address", "erased-serde", "futures", @@ -1056,6 +1123,17 @@ dependencies = [ "digest", ] +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + [[package]] name = "http" version = "0.2.9" @@ -1355,6 +1433,34 @@ dependencies = [ "spin 0.5.2", ] +[[package]] +name = "lettre" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76bd09637ae3ec7bd605b8e135e757980b3968430ff2b1a4a94fb7769e50166d" +dependencies = [ + "async-trait", + "base64 0.21.0", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "once_cell", + "quoted_printable", + "rustls 0.21.0", + "rustls-pemfile", + "socket2", + "tokio", + "tokio-rustls 0.24.0", + "webpki-roots 0.23.0", +] + [[package]] name = "libc" version = "0.2.140" @@ -1427,6 +1533,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + [[package]] name = "matchers" version = "0.1.0" @@ -1466,6 +1578,88 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "mrml" +version = "2.0.0-rc2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b65039434eb297dfdd343ea1e4f28e95366ce6d957de07a24a940d5e760ede1" +dependencies = [ + "indexmap", + "mrml-json-macros", + "mrml-macros", + "mrml-parse-macros", + "mrml-print-macros", + "rand", + "rustc-hash", + "serde", + "serde_json", + "thiserror", + "xmlparser", +] + +[[package]] +name = "mrml-common-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "646bbe56cd666665355ddc095162c603c64511e897bd5033376abdcf8b9be623" +dependencies = [ + "syn 1.0.109", +] + +[[package]] +name = "mrml-json-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f08de794f5dae4be523ef4ae7b35c53e70d6b37fe3995db2e2d0d2dec784677" +dependencies = [ + "Inflector", + "darling 0.14.4", + "mrml-common-macros", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "mrml-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c2c5bffc248ce37d7c1b55389cf4ccae1a7647e4ed295684906c9ab0cd762bd" +dependencies = [ + "Inflector", + "mrml-common-macros", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "mrml-parse-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "012179ed2e5f76915544bc78e8aeed10f49fdc06f60f1250c7a993568569c7c7" +dependencies = [ + "Inflector", + "darling 0.14.4", + "mrml-common-macros", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "mrml-print-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3ed636b13c5df8ad22087f548be69cd1416c2f5929d687406e7c872528bd55" +dependencies = [ + "darling 0.14.4", + "mrml-common-macros", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "multer" version = "2.0.4" @@ -2047,6 +2241,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a24039f627d8285853cc90dcddf8c1ebfaa91f834566948872b225b9a28ed1b6" + [[package]] name = "rand" version = "0.8.5" @@ -2343,6 +2543,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustix" version = "0.36.11" @@ -2369,6 +2575,18 @@ dependencies = [ "webpki", ] +[[package]] +name = "rustls" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07180898a28ed6a7f7ba2311594308f595e3dd2e3c3812fa0a80a47b45f17e5d" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + [[package]] name = "rustls-pemfile" version = "1.0.2" @@ -2378,6 +2596,16 @@ dependencies = [ "base64 0.21.0", ] +[[package]] +name = "rustls-webpki" +version = "0.100.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.12" @@ -2526,7 +2754,7 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" dependencies = [ - "darling", + "darling 0.13.4", "proc-macro2", "quote", "syn 1.0.109", @@ -2712,7 +2940,7 @@ dependencies = [ "once_cell", "paste", "percent-encoding", - "rustls", + "rustls 0.20.8", "rustls-pemfile", "serde", "sha2", @@ -2723,7 +2951,7 @@ dependencies = [ "thiserror", "tokio-stream", "url", - "webpki-roots", + "webpki-roots 0.22.6", ] [[package]] @@ -2756,7 +2984,7 @@ checksum = "804d3f245f894e61b1e6263c84b23ca675d96753b5abfd5cc8597d86806e8024" dependencies = [ "once_cell", "tokio", - "tokio-rustls", + "tokio-rustls 0.23.4", ] [[package]] @@ -2996,11 +3224,21 @@ version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" dependencies = [ - "rustls", + "rustls 0.20.8", "tokio", "webpki", ] +[[package]] +name = "tokio-rustls" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d409377ff5b1e3ca6437aa86c1eb7d40c134bfec254e44c830defa92669db5" +dependencies = [ + "rustls 0.21.0", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.12" @@ -3419,6 +3657,15 @@ dependencies = [ "webpki", ] +[[package]] +name = "webpki-roots" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa54963694b65584e170cf5dc46aeb4dcaa5584e652ff5f3952e56d66aff0125" +dependencies = [ + "rustls-webpki", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3549,6 +3796,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "xmlparser" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d25c75bf9ea12c4040a97f829154768bbbce366287e2dc044af160cd79a13fd" + [[package]] name = "yansi" version = "0.5.1" diff --git a/Dockerfile b/Dockerfile index edc804c..179291f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ FROM alpine:3.17.2 COPY --from=builder /ezidam/target/x86_64-unknown-linux-musl/release/ezidam /ezidam/ezidam COPY crates/ezidam/static /ezidam/static COPY crates/ezidam/templates /ezidam/templates +COPY crates/email/templates /ezidam/email-templates ENV ROCKET_CLI_COLORS=0 ENV ROCKET_ADDRESS=0.0.0.0 ENV ROCKET_PORT=8000 diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml new file mode 100644 index 0000000..56f3fc5 --- /dev/null +++ b/crates/email/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "email" +version = "0.0.0" +edition = "2021" + +[dependencies] +thiserror = { workspace = true } +tera = "1.18.1" +mrml = "2.0.0-rc2" +serde = { workspace = true } + +[dependencies.lettre] +version = "0.10.3" +default-features = false +features = [ + # tokio, rustls + "tokio1", + "tokio1-rustls-tls", + # default features + "smtp-transport", + "pool", + "hostname", + "builder", +] \ No newline at end of file diff --git a/crates/email/readme.md b/crates/email/readme.md new file mode 100644 index 0000000..d0446bc --- /dev/null +++ b/crates/email/readme.md @@ -0,0 +1,36 @@ +# email + +create and send emails from templates + +## template + +- `mjml` to create emails +- `tera` to render emails with parameters + +## send + +- `lettre` to send the emails with smtp + +## rocket config + +- `from`: Sender of emails, usually name and email address +- `transport`: Security method to send the mails. `unencrypted` or `tls` or `starttls` +- `host`: Where to send smtp traffic +- `port`: *optional*: Used when `transport` is `unencrypted` +- `username` and `password`: *optional*: Used when `transport` is `tls` or `starttls` + +### `Rocket.toml` + +```toml +[default.email] +from = "ezidam " +transport = "unencrypted" +host = "localhost" +port = 1025 +``` + +### env + +```dotenv +ROCKET_EMAIL='{from="ezidam ",transport="unencrypted",host="localhost",port=1025}' +``` diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs new file mode 100644 index 0000000..07cef0b --- /dev/null +++ b/crates/email/src/lib.rs @@ -0,0 +1,151 @@ +use lettre::message::header::ContentType; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::response::Response; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use mrml::mjml::Mjml; +use mrml::prelude::parse::Error as MjmlParserError; +use mrml::prelude::render::{Error as MjmlRenderError, Options}; +use serde::{Deserialize, Serialize}; +use tera::{Context, Tera}; + +// error +#[derive(thiserror::Error)] +// the rest +#[derive(Debug)] +pub enum Error { + #[error("Template error: {0}")] + Template(#[from] tera::Error), + + #[error("Mjml parser error: {0}")] + MjmlParser(#[from] MjmlParserError), + + #[error("Mjml render error: {0}")] + MjmlRender(#[from] MjmlRenderError), + + #[error("Invalid email address: {0}")] + EmailAddress(#[from] lettre::address::AddressError), + + #[error("Invalid email message: {0}")] + EmailMessage(#[from] lettre::error::Error), + + #[error("Error when sending email: {0}")] + EmailSend(#[from] lettre::transport::smtp::Error), + + #[error("Invalid {0}")] + InvalidConfig(&'static str), +} + +const TEMPLATE_EXT: &str = ".mjml.tera"; + +pub fn render_template(name: &str, content: C) -> Result { + let base_dir = if cfg!(debug_assertions) { + format!("{}/templates", env!("CARGO_MANIFEST_DIR")) + } else { + "./email-templates".into() + }; + let templates = format!("{base_dir}/**/*{TEMPLATE_EXT}",); + + // Initialize tera templates + let tera = Tera::new(&templates)?; + + // Construct context + let context = Context::from_serialize(content)?; + + // Render template + let template_name = format!("{name}{TEMPLATE_EXT}"); + Ok(tera.render(&template_name, &context)?) +} + +pub fn render_email(mjml: &str) -> Result { + // Parse mjml + let root = Mjml::parse(mjml)?; + + // Render to html + let opts = Options::default(); + Ok(root.render(&opts)?) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Transport { + Unencrypted, + Tls, + StartTls, +} + +#[derive(Debug, Deserialize)] +pub struct Config { + pub from: String, + pub transport: Transport, + pub host: String, + pub port: Option, + pub username: Option, + pub password: Option, +} + +impl Config { + pub fn status(&self) -> Result { + match self.transport { + Transport::Unencrypted => match self.port { + Some(port) => Ok(format!("Using \"{}:{port}\" to send emails", self.host)), + None => Err("Port not provided."), + }, + Transport::Tls | Transport::StartTls => { + if self.username.is_none() { + return Err("Username not provided."); + } + + if self.password.is_none() { + return Err("Password not provided."); + } + + Ok(format!("Using \"{}\" to send emails", self.host)) + } + } + } +} + +fn create_credentials(config: &Config) -> Result { + let username = config + .username + .as_deref() + .ok_or(Error::InvalidConfig("username"))?; + let password = config + .password + .as_deref() + .ok_or(Error::InvalidConfig("password"))?; + + Ok(Credentials::from((username, password))) +} + +pub async fn send_email( + config: &Config, + user: &str, + email_title: &str, + html: String, +) -> Result { + let m = Message::builder() + .from(config.from.parse()?) + .to(user.parse()?) + .subject(email_title) + .header(ContentType::TEXT_HTML) + .body(html)?; + + type SmtpTransport = AsyncSmtpTransport; + + let mailer = match config.transport { + Transport::Unencrypted => { + let port = config.port.ok_or(Error::InvalidConfig("port"))?; + SmtpTransport::builder_dangerous(&config.host).port(port) + } + Transport::Tls => { + SmtpTransport::relay(&config.host)?.credentials(create_credentials(config)?) + } + Transport::StartTls => { + SmtpTransport::starttls_relay(&config.host)?.credentials(create_credentials(config)?) + } + } + .build(); + + Ok(mailer.send(m).await?) +} diff --git a/crates/email/templates/password-reset.mjml.tera b/crates/email/templates/password-reset.mjml.tera new file mode 100644 index 0000000..4d743bc --- /dev/null +++ b/crates/email/templates/password-reset.mjml.tera @@ -0,0 +1,50 @@ + + + {{ title }} + + + + + + + + + + + + + + + +

Reset password

+

You recently requested to reset the password of your ezidam account.

+

Use the button below to reset it. This message will expire in {{ token_duration }} minutes.

+
+ Reset your password + +

Can't click on the button? Use the link below:

+ {{ reset_password_url }} +
+
+
+ + + +

{{ user_email }}

+

{{ now(utc=true) | date(format="%F %T %Z", timezone=user_timezone | default(value="UTC")) }}

+
+
+
+ + + +

ezidam {{ ezidam_version }}

+

Copyright © + {{ now(utc=true) | date(format="%Y", timezone=user_timezone | default(value="UTC")) }} + philt3r technologies +

+
+
+
+
+
diff --git a/crates/ezidam/Cargo.toml b/crates/ezidam/Cargo.toml index 6b6dce5..7357327 100644 --- a/crates/ezidam/Cargo.toml +++ b/crates/ezidam/Cargo.toml @@ -27,3 +27,4 @@ jwt = { path = "../jwt" } apps = { path = "../apps" } authorization_codes = { path = "../authorization_codes" } refresh_tokens = { path = "../refresh_tokens" } +email = { path = "../email" } \ No newline at end of file diff --git a/crates/ezidam/Rocket.toml b/crates/ezidam/Rocket.toml index 1a7264a..eb988b0 100644 --- a/crates/ezidam/Rocket.toml +++ b/crates/ezidam/Rocket.toml @@ -1,2 +1,8 @@ [default.databases.ezidam] url = "../../database/ezidam.sqlite" + +[default.email] +from = "ezidam " +transport = "unencrypted" +host = "localhost" +port = 1025 \ No newline at end of file diff --git a/crates/ezidam/src/main.rs b/crates/ezidam/src/main.rs index e662afe..89f7a13 100644 --- a/crates/ezidam/src/main.rs +++ b/crates/ezidam/src/main.rs @@ -1,9 +1,9 @@ use ezidam::rocket_setup; -use rocket::{main, Error}; +use rocket::main; // see for using rocket with main function https://github.com/intellij-rust/intellij-rust/issues/5975#issuecomment-920620289 #[main] -async fn main() -> Result<(), Error> { +async fn main() -> Result<(), String> { // Custom config // - rocket defaults // - from `Rocket.toml` @@ -11,17 +11,33 @@ async fn main() -> Result<(), Error> { // - from code below let config = rocket::Config::figment().merge(("ip_header", "x-forwarded-for")); + // Get email config + let email_config = match config.extract_inner::("email") { + Ok(email_config) => match email_config.status() { + Ok(status) => { + println!("{status}"); + email_config + } + Err(e) => return Err(format!("Invalid email configuration: {e}")), + }, + Err(e) => return Err(format!("No email configuration was found: {e}")), + }; + println!("Emails will be sent from \"{}\"", email_config.from); + // Rocket with custom config let rocket_builder = rocket::custom(config); // Setup server let rocket_builder = rocket_setup(rocket_builder); + // Attach email config + let rocket_builder = rocket_builder.manage(email_config); + // Launch server - let _ = rocket_builder - .launch() - .await - .expect("Failed to launch server"); + let _ = rocket_builder.launch().await.map_err(|e| e.to_string())?; + + // After shutdown is done + println!("Shutdown is complete. Exiting."); Ok(()) } diff --git a/crates/ezidam/src/routes/root/forgot_password.rs b/crates/ezidam/src/routes/root/forgot_password.rs index 2c1b6f7..3c62b5a 100644 --- a/crates/ezidam/src/routes/root/forgot_password.rs +++ b/crates/ezidam/src/routes/root/forgot_password.rs @@ -1,8 +1,12 @@ use crate::routes::prelude::*; use email_address::EmailAddress; use hash::PaperKey; +use rocket::serde::Serialize; +use rocket::State; use rocket::{get, post}; +use settings::Settings; use std::str::FromStr; +use url::Url; use users::{password_reset::PasswordResetToken, User}; #[get("/forgot-password")] @@ -19,12 +23,26 @@ pub struct ForgotPasswordEmailForm<'r> { pub email: &'r str, } +#[derive(Serialize)] +#[serde(crate = "rocket::serde")] +#[derive(Clone)] +pub struct ResetPasswordEmail { + pub title: String, + pub ezidam_version: &'static str, + pub logo_url: String, + pub token_duration: i64, + pub reset_password_url: String, + pub user_email: String, + pub user_timezone: String, +} + const SUCCESS_MESSAGE: &str = "An email is on the way with instructions to reset your password."; #[post("/forgot-password/email", data = "
")] pub async fn forgot_password_email_form( mut db: Connection, form: Form>, + email_config: &State, ) -> Result> { if form.email.is_empty() { return Ok(Flash::new( @@ -48,6 +66,24 @@ pub async fn forgot_password_email_form( let mut transaction = db.begin().await?; + // Get settings + let settings = Settings::get(&mut transaction).await?; + + // Get server url + let url = settings + .url() + .ok_or_else(|| Error::not_found("Server url"))?; + let url = match Url::parse(url) { + Ok(url) => url, + Err(_) => { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + "Failed to parse server url", + )); + } + }; + // Get user let user = match User::get_by_email(&mut transaction, &email).await? { Some(user) => user, @@ -61,7 +97,8 @@ pub async fn forgot_password_email_form( }; // Generate reset token - let token = task::spawn_blocking(|| PasswordResetToken::generate(15)).await?; + let token_duration = 15; + let token = task::spawn_blocking(move || PasswordResetToken::generate(token_duration)).await?; // Save in database user.set_password_reset_token(&mut transaction, Some(&token)) @@ -69,12 +106,95 @@ pub async fn forgot_password_email_form( transaction.commit().await?; - // TODO: send email here + // Construct url to reset password + let token = RocketResetPasswordToken(token); + let uri = uri!(super::reset_password_page(token)).to_string(); + let reset_url = match url.join(&uri) { + Ok(url) => url, + Err(_) => { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + "Failed to construct url to reset password ", + )); + } + }; + + // Url to logo + let logo_url = match url.join("/logo") { + Ok(url) => url, + Err(_) => { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + "Failed to construct url to business logo", + )); + } + }; + + let email_title = "Reset password - ezidam"; + + let content = ResetPasswordEmail { + title: email_title.into(), + ezidam_version: env!("CARGO_PKG_VERSION"), + logo_url: logo_url.to_string(), + token_duration, + reset_password_url: reset_url.to_string(), + user_email: email.to_string(), + user_timezone: user.timezone().into(), + }; + + // Render email template + let mjml = match email::render_template("password-reset", &content) { + Ok(mjml) => mjml, + Err(e) => { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + format!("Failed to create email: {e}"), + )); + } + }; + + // Create html email + let html = match email::render_email(&mjml) { + Ok(html) => html, + Err(e) => { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + format!("Failed to render email: {e}"), + )); + } + }; + + let user_for_email = match user.name() { + Some(name) => { + format!("{name} <{email}>") + } + None => email.to_string(), + }; + + // Send email + let (flash_kind, flash_message) = + match email::send_email(email_config, &user_for_email, email_title, html).await { + Ok(okay) => { + if okay.is_positive() { + (FlashKind::Success, SUCCESS_MESSAGE.to_string()) + } else { + ( + FlashKind::Warning, + "Email should be on it's way, but it might not arrive".into(), + ) + } + } + Err(e) => (FlashKind::Danger, e.to_string()), + }; Ok(Flash::new( Redirect::to(uri!(forgot_password_page)), - FlashKind::Success, - SUCCESS_MESSAGE, + flash_kind, + flash_message, )) }