forgot password: send emails

This commit is contained in:
Philippe Loctaux 2023-04-22 01:27:24 +02:00
parent 751a21485f
commit feb9f16bc9
10 changed files with 676 additions and 18 deletions

24
crates/email/Cargo.toml Normal file
View file

@ -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",
]

36
crates/email/readme.md Normal file
View file

@ -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 <ezidam@mail.local>"
transport = "unencrypted"
host = "localhost"
port = 1025
```
### env
```dotenv
ROCKET_EMAIL='{from="ezidam <ezidam@mail.local>",transport="unencrypted",host="localhost",port=1025}'
```

151
crates/email/src/lib.rs Normal file
View file

@ -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<C: Serialize>(name: &str, content: C) -> Result<String, Error> {
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<String, Error> {
// 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<u16>,
pub username: Option<String>,
pub password: Option<String>,
}
impl Config {
pub fn status(&self) -> Result<String, &'static str> {
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<Credentials, Error> {
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<Response, Error> {
let m = Message::builder()
.from(config.from.parse()?)
.to(user.parse()?)
.subject(email_title)
.header(ContentType::TEXT_HTML)
.body(html)?;
type SmtpTransport = AsyncSmtpTransport<Tokio1Executor>;
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?)
}

View file

@ -0,0 +1,50 @@
<mjml>
<mj-head>
<mj-title>{{ title }}</mj-title>
<mj-attributes>
<mj-all font-family="Arial"/>
<mj-text align="center"/>
</mj-attributes>
</mj-head>
<mj-body background-color="#eee">
<mj-section>
<mj-column>
<mj-image width="100px" src="{{ logo_url }}" alt="business logo"/>
<mj-divider border-color="#3a88fe"/>
</mj-column>
</mj-section>
<mj-section background-color="#fff">
<mj-column>
<mj-text>
<h2>Reset password</h2>
<p>You recently requested to reset the password of your ezidam account.</p>
<p>Use the button below to reset it. This message will expire in {{ token_duration }} minutes.</p>
</mj-text>
<mj-button href="{{ reset_password_url }}" background-color="#3a88fe">Reset your password</mj-button>
<mj-text>
<p>Can't click on the button? Use the link below:</p>
<a href="{{ reset_password_url }}">{{ reset_password_url }}</a>
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text>
<p>{{ user_email }}</p>
<p>{{ now(utc=true) | date(format="%F %T %Z", timezone=user_timezone | default(value="UTC")) }}</p>
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text color="#696969" font-size="10px">
<p>ezidam {{ ezidam_version }}</p>
<p>Copyright ©
{{ now(utc=true) | date(format="%Y", timezone=user_timezone | default(value="UTC")) }}
<a href="https://philippeloctaux.com">philt3r technologies</a>
</p>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

View file

@ -27,3 +27,4 @@ jwt = { path = "../jwt" }
apps = { path = "../apps" }
authorization_codes = { path = "../authorization_codes" }
refresh_tokens = { path = "../refresh_tokens" }
email = { path = "../email" }

View file

@ -1,2 +1,8 @@
[default.databases.ezidam]
url = "../../database/ezidam.sqlite"
[default.email]
from = "ezidam <ezidam@mail.local>"
transport = "unencrypted"
host = "localhost"
port = 1025

View file

@ -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::Config>("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(())
}

View file

@ -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 = "<form>")]
pub async fn forgot_password_email_form(
mut db: Connection<Database>,
form: Form<ForgotPasswordEmailForm<'_>>,
email_config: &State<email::Config>,
) -> Result<Flash<Redirect>> {
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,
))
}