forgot password: send emails
This commit is contained in:
parent
751a21485f
commit
feb9f16bc9
10 changed files with 676 additions and 18 deletions
24
crates/email/Cargo.toml
Normal file
24
crates/email/Cargo.toml
Normal 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
36
crates/email/readme.md
Normal 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
151
crates/email/src/lib.rs
Normal 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?)
|
||||
}
|
||||
50
crates/email/templates/password-reset.mjml.tera
Normal file
50
crates/email/templates/password-reset.mjml.tera
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue