refactor: use new mailer struct

This commit is contained in:
Max Hohlfeld 2025-01-24 15:15:57 +01:00
parent 760e19522b
commit 8b470f2d4f
15 changed files with 238 additions and 180 deletions

View File

@ -1,11 +1,11 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use lettre::{SmtpTransport, Transport};
use serde::Deserialize;
use sqlx::PgPool;
use crate::{
mail::Mailer,
models::{Function, Registration, Role, User},
utils::{email, ApplicationError},
utils::ApplicationError,
};
#[derive(Deserialize)]
@ -22,7 +22,7 @@ pub async fn post_new(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
form: web::Form<NewUserForm>,
mailer: web::Data<SmtpTransport>,
mailer: web::Data<Mailer>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::AreaManager && user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
@ -49,9 +49,7 @@ pub async fn post_new(
let registration = Registration::insert_new_for_user(pool.get_ref(), id).await?;
let new_user = User::read_by_id(pool.get_ref(), id).await?.unwrap();
let message = email::build_registration_message(&new_user, &registration.token)?;
mailer.send(&message)?;
mailer.send_registration_mail(&new_user, &registration.token)?;
Ok(HttpResponse::Found()
.insert_header((LOCATION, "/users"))

View File

@ -1,12 +1,12 @@
use actix_web::{web, HttpResponse, Responder};
use lettre::{SmtpTransport, Transport};
use serde::Deserialize;
use sqlx::PgPool;
use crate::{
endpoints::user::handle_password_change_request,
mail::Mailer,
models::{PasswordReset, User},
utils::{email, ApplicationError},
utils::ApplicationError,
};
#[derive(Deserialize, Debug)]
@ -22,7 +22,7 @@ struct ResetPasswordForm {
async fn post(
form: web::Form<ResetPasswordForm>,
pool: web::Data<PgPool>,
mailer: web::Data<SmtpTransport>,
mailer: web::Data<Mailer>,
) -> Result<impl Responder, ApplicationError> {
if form.email.is_some()
&& form.token.is_none()
@ -31,10 +31,7 @@ async fn post(
{
if let Ok(user) = User::read_for_login(pool.get_ref(), form.email.as_ref().unwrap()).await {
let reset = PasswordReset::insert_new_for_user(pool.get_ref(), user.id).await?;
let message = email::build_forgot_password_message(&user, &reset.token);
mailer.send(&message)?;
mailer.send_forgot_password_mail(&user, &reset.token)?;
}
return Ok(HttpResponse::Ok().body("E-Mail versandt!"));

View File

@ -0,0 +1,96 @@
use lettre::{
message::{Mailbox, MultiPart, SinglePart},
Message, Transport,
};
use rinja::Template;
use crate::{models::User, utils::ApplicationError};
use super::Mailer;
impl Mailer {
pub fn send_forgot_password_mail(
&self,
user: &User,
token: &str,
) -> Result<(), ApplicationError> {
let message = build(&self.hostname, &user.name, &user.email, token)?;
self.transport.send(&message)?;
Ok(())
}
}
#[derive(Template)]
#[template(path = "emails/forgot_password.txt")]
struct ForgotPasswordMailTemplatePlain<'a> {
name: &'a str,
reset_url: &'a str,
}
#[derive(Template)]
#[template(path = "emails/forgot_password.html")]
struct ForgotPasswordMailTemplateHtml<'a> {
name: &'a str,
reset_url: &'a str,
}
fn build(
hostname: &str,
name: &str,
email: &str,
token: &str,
) -> Result<Message, ApplicationError> {
let reset_url = format!("https://{hostname}/reset-password?token={token}");
let plain = ForgotPasswordMailTemplatePlain {
name,
reset_url: &reset_url,
}
.to_string();
let html = ForgotPasswordMailTemplateHtml {
name,
reset_url: &reset_url,
}
.to_string();
let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse()?)
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse()?)
.to(Mailbox::new(Some(name.to_string()), email.parse()?))
.subject("Brass: Zurücksetzen des Passworts angefordert")
.multipart(
MultiPart::alternative()
.singlepart(SinglePart::plain(plain))
.singlepart(SinglePart::html(html)),
)
.unwrap();
Ok(message)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::test_helper::assert_mail_snapshot;
use lettre::transport::stub::StubTransport;
#[test]
fn build_mail_snapshot() {
let message = build(
"brasiwa-leipzig.de",
"Max Mustermann",
"max@example.com",
"123456789",
)
.unwrap();
let sender = StubTransport::new_ok();
sender.send(&message).unwrap();
let messages = sender.messages();
let (_, sent_message) = messages.first().unwrap();
assert_mail_snapshot!(sent_message);
}
}

View File

@ -11,14 +11,18 @@ use crate::utils::ApplicationError;
mod forgot_password;
mod registration;
mod testmail;
#[derive(Clone)]
pub struct Mailer {
transport: Transports,
hostname: String,
}
#[derive(Clone)]
enum Transports {
SmtpTransport(SmtpTransport),
#[allow(unused)]
StubTransport(StubTransport),
}
@ -75,3 +79,14 @@ impl Mailer {
Ok(mailer)
}
}
#[cfg(test)]
impl Mailer {
pub fn new_stub() -> Self {
Mailer {
transport: Transports::StubTransport(StubTransport::new_ok()),
hostname: String::from("testhostname")
}
}
}

View File

@ -1,5 +1,5 @@
use lettre::{
message::{header::ContentType, Mailbox, MultiPart, SinglePart},
message::{Mailbox, MultiPart, SinglePart},
Message, Transport,
};
use rinja::Template;
@ -62,23 +62,21 @@ fn build(
.subject("Brass: Registrierung deines Accounts")
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(plain),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html),
),
.singlepart(SinglePart::plain(plain))
.singlepart(SinglePart::html(html)),
)?;
Ok(message)
}
#[test]
fn build_mail_snapshot() {
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::test_helper::assert_mail_snapshot;
use lettre::transport::stub::StubTransport;
#[test]
fn build_mail_snapshot() {
let message = build(
"brasiwa-leipzig.de",
"Max Mustermann",
@ -87,10 +85,11 @@ fn build_mail_snapshot() {
)
.unwrap();
let sender = lettre::transport::stub::StubTransport::new_ok();
let sender = StubTransport::new_ok();
sender.send(&message).unwrap();
let messages = sender.messages();
let (_, sent_message) = messages.first().unwrap();
crate::utils::test_helper::assert_mail_snapshot!(sent_message);
assert_mail_snapshot!(sent_message);
}
}

View File

@ -0,0 +1,46 @@
---
source: web/src/mail/forgot_password.rs
expression: sent_message
snapshot_kind: text
---
From: noreply <noreply@brasiwa-leipzig.de>
Reply-To: noreply <noreply@brasiwa-leipzig.de>
To: "Max Mustermann" <max@example.com>
Subject: Brass: =?utf-8?b?WnVyw7xja3NldHplbg==?= des Passworts angefordert
MIME-Version: 1.0
Date: Date
Content-Type: multipart/alternative;
boundary="boundary"
--boundary
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
Hallo Max Mustermann,
du hast angefordert, dein Passwort zur=C3=BCckzusetzen. Kopiere daf=C3=BCr =
folgenden Link in deinen Browser:
https://brasiwa-leipzig.de/reset-password?token=3D123456789
Bitte beachte, dass der Link nur 24 Stunden g=C3=BCltig ist. Solltest du ni=
chts angefordert haben, dann musst du nichts weiter tun.
Viele Gr=C3=BC=C3=9Fe
--boundary
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
<p>Hallo Max Mustermann,</p>
<p>du hast angefordert, dein Passwort zur=C3=BCckzusetzen. Klicke daf=C3=BC=
r <a href=3D"https://brasiwa-leipzig.de/reset-password?token=3D123456789" t=
arget=3D"_blank">hier</a> oder kopiere folgenden Link in deinen Browser:</p>
<p>https://brasiwa-leipzig.de/reset-password?token=3D123456789</p>
<p>Bitte beachte, dass der Link <b>nur 24 Stunden g=C3=BCltig</b> ist. Soll=
test du nichts angefordert haben, dann musst du nichts weiter tun.</p>
<p>Viele Gr=C3=BC=C3=9Fe</p>
--boundary--

21
web/src/mail/testmail.rs Normal file
View File

@ -0,0 +1,21 @@
use lettre::{message::SinglePart, Message, Transport};
use crate::utils::ApplicationError;
use super::Mailer;
impl Mailer {
pub fn send_test_mail(&self, to: &str) -> Result<(), ApplicationError> {
let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse()?)
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse()?)
.to(to.parse()?)
.subject("Brass: Test E-Mail")
.singlepart(SinglePart::plain(
"Testmail von Brass. E-Mail Versand funktioniert!".to_string(),
))?;
self.transport.send(&message)?;
Ok(())
}
}

View File

@ -9,7 +9,7 @@ use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse};
use actix_web::{web, App, HttpServer};
use actix_web_static_files::ResourceFiles;
use brass_config::{get_env, load_config, Config};
use lettre::Transport;
use mail::Mailer;
use sqlx::postgres::PgPool;
use sqlx::{Pool, Postgres};
@ -36,7 +36,7 @@ async fn main() -> anyhow::Result<()> {
let args = parse_args()?;
let pool = PgPool::connect(&config.database_url).await?;
let mailer = utils::email::get_mailer(&config)?;
let mailer = Mailer::new(&config)?;
handle_command(args.command, &pool, &mailer).await?;
@ -52,10 +52,10 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
pub fn create_app<T>(
pub fn create_app(
config: Config,
pool: Pool<Postgres>,
mailer: T,
mailer: Mailer,
) -> App<
impl ServiceFactory<
ServiceRequest,
@ -64,7 +64,7 @@ pub fn create_app<T>(
InitError = (),
Error = actix_web::error::Error,
>,
> where T: Transport + 'static {
> {
let generated = generate();
let secret_key = Key::from(config.secret_key.as_bytes());
let store = SqlxPostgresqlSessionStore::from_pool(pool.clone().into());

View File

@ -1,114 +0,0 @@
use std::env;
use brass_config::{Config, SmtpTlsType};
use lettre::{
message::{header::ContentType, Mailbox, MultiPart, SinglePart},
transport::smtp::{authentication::Credentials, extension::ClientId},
Message, SmtpTransport,
};
use crate::models::User;
use super::ApplicationError;
pub fn get_mailer(config: &Config) -> anyhow::Result<SmtpTransport> {
let mut builder = match config.smtp_tlstype {
SmtpTlsType::StartTLS => SmtpTransport::starttls_relay(&config.smtp_server)?.port(config.smtp_port),
SmtpTlsType::TLS => SmtpTransport::relay(&config.smtp_server)?.port(config.smtp_port),
SmtpTlsType::NoTLS => SmtpTransport::builder_dangerous(&config.smtp_server).port(config.smtp_port),
};
if let (Some(login), Some(password)) = (config.smtp_login.as_ref(), config.smtp_password.as_ref()) {
builder = builder.credentials(Credentials::new(login.to_string(), password.to_string()));
}
let mailer = builder
.hello_name(ClientId::Domain(config.hostname.clone()))
.build();
Ok(mailer)
}
pub fn build_forgot_password_message(user: &User, token: &str) -> Message {
let hostname = env::var("HOSTNAME").unwrap();
let reset_url = format!("https://{hostname}/reset-password?token={token}");
let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap())
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap())
.to(format!("{} <{}>", user.name, user.email).parse().unwrap())
.subject("Brass: Zurücksetzen des Passworts angefordert")
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(format!(r##"Hallo {},
du hast angefordert, dein Passwort zurückzusetzen. Kopiere dafür folgenden Link in deinen Browser:
{reset_url}
Bitte beachte, dass der Link nur 24 Stunden gültig ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun.
Viele Grüße"##, user.name))
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(format!(r##"<p>Hallo {},</p>
<p>du hast angefordert, dein Passwort zurückzusetzen. Klicke dafür <a href="{reset_url}" target="_blank">hier</a> oder kopiere folgenden Link in deinen Browser:</p>
<p>{reset_url}</p>
<p>Bitte beachte, dass der Link <b>nur 24 Stunden gültig</b> ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun.</p>
<p>Viele Grüße</p>"##, user.name))
))
.unwrap();
return message;
}
pub fn build_registration_message(user: &User, token: &str) -> Result<Message, ApplicationError> {
let hostname = env::var("HOSTNAME")?;
let register_url = format!("https://{hostname}/register?token={token}");
let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse()?)
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse()?)
.to(Mailbox::new(Some(user.name.clone()), user.email.parse()?))
.subject("Brass: Registrierung deines Accounts")
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(format!(r##"Hallo {},
dein Account für https:://{hostname} wurde erstellt. Du musst nur noch ein Passwort festlegen. Kopiere dafür folgenden Link in deinen Browser:
{register_url}
Bitte beachte, dass der Link nur 24 Stunden gültig ist.
Viele Grüße"##, user.name))
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(format!(r##"<p>Hallo {},</p>
<p>dein Account für <a href="https:://{hostname}" target="_blank">https://{hostname}</a> wurde erstellt. Du musst nur noch ein Passwort festlegen. Klicke dafür <a href="{register_url}" target="_blank">hier</a> oder kopiere folgenden Link in deinen Browser:</p>
<p>{register_url}</p>
<p>Bitte beachte, dass der Link <b>nur 24 Stunden gültig</b> ist.</p>
<p>Viele Grüße</p>"##, user.name))
))
?;
Ok(message)
}

View File

@ -4,13 +4,10 @@ use std::{
process::exit,
};
use lettre::{
message::{header::ContentType, SinglePart},
Message, SmtpTransport, Transport,
};
use sqlx::{Pool, Postgres};
use crate::{
mail::Mailer,
models::{Function, Role, User},
utils::auth::generate_salt_and_hash_plain_password,
};
@ -65,7 +62,7 @@ fn prompt(prompt: &str) -> anyhow::Result<String> {
pub async fn handle_command(
command: Option<Command>,
pool: &Pool<Postgres>,
mailer: &SmtpTransport,
mailer: &Mailer,
) -> anyhow::Result<()> {
match command {
Some(Command::Migrate) => {
@ -101,19 +98,7 @@ pub async fn handle_command(
exit(0);
}
Some(Command::TestMail(to)) => {
let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap())
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap())
.to(to.parse().unwrap())
.subject("Brass: Test E-Mail")
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body("Testmail von Brass. E-Mail Versand funktioniert!".to_string()),
)
.unwrap();
match mailer.send(&message) {
match mailer.send_test_mail(&to) {
Ok(_) => println!("Successfully sent mail to {to}."),
Err(e) => {
if let Some(source) = e.source() {
@ -123,7 +108,6 @@ pub async fn handle_command(
}
}
}
exit(0);
}
None => (),

View File

@ -1,6 +1,5 @@
mod application_error;
pub mod auth;
pub mod email;
pub mod event_planning_template;
pub mod manage_commands;
pub mod password_help;

View File

@ -6,10 +6,9 @@ use actix_web::{
dev::{Service, ServiceResponse},
test::init_service,
};
use lettre::transport::stub::StubTransport;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use crate::create_app;
use crate::{create_app, mail::Mailer};
use brass_config::{load_config, Config, Environment};
use regex::{Captures, Regex};
use sqlx::{
@ -33,7 +32,7 @@ impl DbTestContext {
init_service(create_app(
self.config.clone(),
self.db_pool.clone(),
StubTransport::new_ok(),
Mailer::new_stub(),
))
.await
}

View File

@ -0,0 +1,9 @@
<p>Hallo {{ name }},</p>
<p>du hast angefordert, dein Passwort zurückzusetzen. Klicke dafür <a href="{{ reset_url }}" target="_blank">hier</a> oder kopiere folgenden Link in deinen Browser:</p>
<p>{{ reset_url }}</p>
<p>Bitte beachte, dass der Link <b>nur 24 Stunden gültig</b> ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun.</p>
<p>Viele Grüße</p>

View File

@ -0,0 +1,9 @@
Hallo {{ name }},
du hast angefordert, dein Passwort zurückzusetzen. Kopiere dafür folgenden Link in deinen Browser:
{{ reset_url }}
Bitte beachte, dass der Link nur 24 Stunden gültig ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun.
Viele Grüße