diff --git a/web/src/endpoints/user/post_new.rs b/web/src/endpoints/user/post_new.rs index 8a961355..4b0a45c6 100644 --- a/web/src/endpoints/user/post_new.rs +++ b/web/src/endpoints/user/post_new.rs @@ -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, pool: web::Data, form: web::Form, - mailer: web::Data, + mailer: web::Data, ) -> Result { 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, ®istration.token)?; - - mailer.send(&message)?; + mailer.send_registration_mail(&new_user, ®istration.token)?; Ok(HttpResponse::Found() .insert_header((LOCATION, "/users")) diff --git a/web/src/endpoints/user/post_reset.rs b/web/src/endpoints/user/post_reset.rs index 4a2d3e43..fc7552e7 100644 --- a/web/src/endpoints/user/post_reset.rs +++ b/web/src/endpoints/user/post_reset.rs @@ -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, pool: web::Data, - mailer: web::Data, + mailer: web::Data, ) -> Result { 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!")); diff --git a/web/src/mail/forgot_password.rs b/web/src/mail/forgot_password.rs index e69de29b..20641fb2 100644 --- a/web/src/mail/forgot_password.rs +++ b/web/src/mail/forgot_password.rs @@ -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 { + 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 ".parse()?) + .reply_to("noreply ".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); + } +} diff --git a/web/src/mail/mod.rs b/web/src/mail/mod.rs index 2f4b5e41..ff900ac0 100644 --- a/web/src/mail/mod.rs +++ b/web/src/mail/mod.rs @@ -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") + } + } +} diff --git a/web/src/mail/registration.rs b/web/src/mail/registration.rs index dafa49af..ab0dd1e6 100644 --- a/web/src/mail/registration.rs +++ b/web/src/mail/registration.rs @@ -1,5 +1,5 @@ use lettre::{ - message::{header::ContentType, Mailbox, MultiPart, SinglePart}, + message::{Mailbox, MultiPart, SinglePart}, Message, Transport, }; use rinja::Template; @@ -62,35 +62,34 @@ 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() { - let message = build( - "brasiwa-leipzig.de", - "Max Mustermann", - "max@example.com", - "123456789", - ) - .unwrap(); +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::test_helper::assert_mail_snapshot; + use lettre::transport::stub::StubTransport; - let sender = lettre::transport::stub::StubTransport::new_ok(); - sender.send(&message).unwrap(); - let messages = sender.messages(); - let (_, sent_message) = messages.first().unwrap(); + #[test] + fn build_mail_snapshot() { + let message = build( + "brasiwa-leipzig.de", + "Max Mustermann", + "max@example.com", + "123456789", + ) + .unwrap(); - crate::utils::test_helper::assert_mail_snapshot!(sent_message); + let sender = StubTransport::new_ok(); + sender.send(&message).unwrap(); + let messages = sender.messages(); + let (_, sent_message) = messages.first().unwrap(); + + assert_mail_snapshot!(sent_message); + } } diff --git a/web/src/mail/snapshots/brass_web__mail__forgot_password__tests__build_mail_snapshot.snap b/web/src/mail/snapshots/brass_web__mail__forgot_password__tests__build_mail_snapshot.snap new file mode 100644 index 00000000..62e696f6 --- /dev/null +++ b/web/src/mail/snapshots/brass_web__mail__forgot_password__tests__build_mail_snapshot.snap @@ -0,0 +1,46 @@ +--- +source: web/src/mail/forgot_password.rs +expression: sent_message +snapshot_kind: text +--- +From: noreply +Reply-To: noreply +To: "Max Mustermann" +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 + +

Hallo Max Mustermann,

+ +

du hast angefordert, dein Passwort zur=C3=BCckzusetzen. Klicke daf=C3=BC= +r hier oder kopiere 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. Soll= +test du nichts angefordert haben, dann musst du nichts weiter tun.

+ +

Viele Gr=C3=BC=C3=9Fe

+--boundary-- diff --git a/web/src/mail/snapshots/brass_web__mail__registration__build_mail_snapshot.snap b/web/src/mail/snapshots/brass_web__mail__registration__tests__build_mail_snapshot.snap similarity index 100% rename from web/src/mail/snapshots/brass_web__mail__registration__build_mail_snapshot.snap rename to web/src/mail/snapshots/brass_web__mail__registration__tests__build_mail_snapshot.snap diff --git a/web/src/mail/testmail.rs b/web/src/mail/testmail.rs new file mode 100644 index 00000000..1a68f818 --- /dev/null +++ b/web/src/mail/testmail.rs @@ -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 ".parse()?) + .reply_to("noreply ".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(()) + } +} diff --git a/web/src/main.rs b/web/src/main.rs index 8fd758df..28e52ad3 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -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( +pub fn create_app( config: Config, pool: Pool, - mailer: T, + mailer: Mailer, ) -> App< impl ServiceFactory< ServiceRequest, @@ -64,7 +64,7 @@ pub fn create_app( 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()); diff --git a/web/src/utils/email.rs b/web/src/utils/email.rs deleted file mode 100644 index de4a16ca..00000000 --- a/web/src/utils/email.rs +++ /dev/null @@ -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 { - 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 ".parse().unwrap()) - .reply_to("noreply ".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##"

Hallo {},

- -

du hast angefordert, dein Passwort zurückzusetzen. Klicke dafür hier oder kopiere 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)) - )) - .unwrap(); - - return message; -} - -pub fn build_registration_message(user: &User, token: &str) -> Result { - let hostname = env::var("HOSTNAME")?; - let register_url = format!("https://{hostname}/register?token={token}"); - - let message = Message::builder() - .from("noreply ".parse()?) - .reply_to("noreply ".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##"

Hallo {},

- -

dein Account für https://{hostname} wurde erstellt. Du musst nur noch ein Passwort festlegen. Klicke dafür hier oder kopiere folgenden Link in deinen Browser:

- -

{register_url}

- -

Bitte beachte, dass der Link nur 24 Stunden gültig ist.

- -

Viele Grüße

"##, user.name)) - )) - ?; - - Ok(message) -} diff --git a/web/src/utils/manage_commands.rs b/web/src/utils/manage_commands.rs index 4ce55e87..a4605b70 100644 --- a/web/src/utils/manage_commands.rs +++ b/web/src/utils/manage_commands.rs @@ -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 { pub async fn handle_command( command: Option, pool: &Pool, - 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 ".parse().unwrap()) - .reply_to("noreply ".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 => (), diff --git a/web/src/utils/mod.rs b/web/src/utils/mod.rs index 8c619e14..cba7f012 100644 --- a/web/src/utils/mod.rs +++ b/web/src/utils/mod.rs @@ -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; diff --git a/web/src/utils/test_helper/test_context.rs b/web/src/utils/test_helper/test_context.rs index 3d125846..092b4761 100644 --- a/web/src/utils/test_helper/test_context.rs +++ b/web/src/utils/test_helper/test_context.rs @@ -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 } diff --git a/web/templates/emails/forgot_password.html b/web/templates/emails/forgot_password.html new file mode 100644 index 00000000..b5b34daa --- /dev/null +++ b/web/templates/emails/forgot_password.html @@ -0,0 +1,9 @@ +

Hallo {{ name }},

+ +

du hast angefordert, dein Passwort zurückzusetzen. Klicke dafür hier oder kopiere 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

diff --git a/web/templates/emails/forgot_password.txt b/web/templates/emails/forgot_password.txt new file mode 100644 index 00000000..b2e07f82 --- /dev/null +++ b/web/templates/emails/forgot_password.txt @@ -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