From 2e12f43c0eb452f21dc2a97c5af22ad98d5d120b Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 19 Jan 2022 12:10:03 +0100 Subject: [PATCH] WIP: email sending crate --- Cargo.lock | 60 ++++++++++++++ crates/email/Cargo.toml | 18 +++++ crates/email/src/lib.rs | 78 +++++++++++++++++++ crates/templates/src/context.rs | 45 ++++++++++- crates/templates/src/lib.rs | 17 +++- .../src/res/emails/verification.html | 23 ++++++ .../templates/src/res/emails/verification.txt | 23 ++++++ 7 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 crates/email/Cargo.toml create mode 100644 crates/email/src/lib.rs create mode 100644 crates/templates/src/res/emails/verification.html create mode 100644 crates/templates/src/res/emails/verification.txt diff --git a/Cargo.lock b/Cargo.lock index 8ee2b0cb..8cd0f87a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1204,6 +1204,17 @@ dependencies = [ "digest 0.10.1", ] +[[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.6" @@ -1446,6 +1457,32 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.10.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d8da8f34d086b081c9cc3b57d3bb3b51d16fc06b5c848a188e2f14d58ac2a5" +dependencies = [ + "async-trait", + "base64 0.13.0", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "once_cell", + "quoted_printable", + "regex", + "rustls 0.20.2", + "rustls-pemfile", + "tokio", + "tokio-rustls 0.23.2", + "tracing", + "webpki-roots 0.22.2", +] + [[package]] name = "libc" version = "0.2.112" @@ -1565,6 +1602,17 @@ dependencies = [ "url", ] +[[package]] +name = "mas-email" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "lettre", + "mas-templates", + "tokio", +] + [[package]] name = "mas-handlers" version = "0.1.0" @@ -1760,6 +1808,12 @@ dependencies = [ "warp", ] +[[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" @@ -2525,6 +2579,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fee2dce59f7a43418e3382c766554c614e06a552d53a8f07ef499ea4b332c0f" + [[package]] name = "rand" version = "0.8.4" diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml new file mode 100644 index 00000000..48df385e --- /dev/null +++ b/crates/email/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mas-email" +version = "0.1.0" +authors = ["Quentin Gliech "] +edition = "2021" +license = "Apache-2.0" + +[dependencies] +tokio = { version = "1.15.0", features = ["macros"] } + +mas-templates = { path = "../templates" } +anyhow = "1.0.52" +async-trait = "0.1.52" + +[dependencies.lettre] +version = "0.10.0-rc.4" +default-features = false +features = ["tokio1-rustls-tls", "hostname", "builder", "tracing", "pool"] diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs new file mode 100644 index 00000000..b47f2b52 --- /dev/null +++ b/crates/email/src/lib.rs @@ -0,0 +1,78 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use lettre::{ + message::{Mailbox, MessageBuilder, MultiPart}, + AsyncTransport, Message, +}; +use mas_templates::{EmailVerificationContext, Templates}; + +pub struct Mailer { + templates: Templates, + from: Mailbox, + reply_to: Mailbox, +} + +impl Mailer { + pub fn new(templates: &Templates, from: Mailbox, reply_to: Mailbox) -> Self { + Self { + templates: templates.clone(), + from, + reply_to, + } + } + + fn base_message(&self) -> MessageBuilder { + Message::builder() + .from(self.from.clone()) + .reply_to(self.reply_to.clone()) + } + + async fn prepare_verification_email( + &self, + to: Mailbox, + context: &EmailVerificationContext, + ) -> anyhow::Result { + let plain = self + .templates + .render_email_verification_txt(context) + .await?; + + let html = self + .templates + .render_email_verification_html(context) + .await?; + + let multipart = MultiPart::alternative_plain_html(plain, html); + + let message = self.base_message().to(to).multipart(multipart)?; + + Ok(message) + } + + pub async fn send_verification_email( + &self, + transport: &T, + to: Mailbox, + context: &EmailVerificationContext, + ) -> anyhow::Result<()> + where + T: AsyncTransport + Send + Sync, + T::Error: std::error::Error + Send + Sync + 'static, + { + let message = self.prepare_verification_email(to, context).await?; + transport.send(message).await?; + Ok(()) + } +} diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 0bb6b906..df32dff9 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -17,7 +17,7 @@ #![allow(clippy::trait_duplication_in_bounds)] use mas_data_model::{ - errors::ErroredForm, AuthorizationGrant, BrowserSession, StorageBackend, UserEmail, + errors::ErroredForm, AuthorizationGrant, BrowserSession, StorageBackend, User, UserEmail, }; use oauth2_types::errors::OAuth2Error; use serde::{ser::SerializeStruct, Serialize}; @@ -76,6 +76,15 @@ pub trait TemplateContext: Serialize { Self: Sized; } +impl TemplateContext for () { + fn sample() -> Vec + where + Self: Sized, + { + Vec::new() + } +} + /// Context with a CSRF token in it #[derive(Serialize)] pub struct WithCsrf { @@ -440,6 +449,40 @@ impl TemplateContext for AccountEmailsContext { } } +/// Context used by the `emails/verification.{txt,html}` templates +#[derive(Serialize)] +pub struct EmailVerificationContext { + user: User<()>, + verification_link: Url, +} + +impl EmailVerificationContext { + #[must_use] + pub fn new(user: User<()>, verification_link: Url) -> Self { + Self { + user, + verification_link, + } + } +} + +impl TemplateContext for EmailVerificationContext { + fn sample() -> Vec + where + Self: Sized, + { + User::samples() + .into_iter() + .map(|u| { + Self::new( + u, + Url::parse("https://example.com/emails/verify?code=2134").unwrap(), + ) + }) + .collect() + } +} + /// Context used by the `form_post.html` template #[derive(Serialize)] pub struct FormPostContext { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 10ce57cc..d019fee0 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -48,10 +48,10 @@ mod functions; mod macros; pub use self::context::{ - AccountContext, AccountEmailsContext, EmptyContext, ErrorContext, FormPostContext, - IndexContext, LoginContext, LoginFormField, PostAuthContext, ReauthContext, ReauthFormField, - RegisterContext, RegisterFormField, TemplateContext, WithCsrf, WithOptionalSession, - WithSession, + AccountContext, AccountEmailsContext, EmailVerificationContext, EmptyContext, ErrorContext, + FormPostContext, IndexContext, LoginContext, LoginFormField, PostAuthContext, ReauthContext, + ReauthFormField, RegisterContext, RegisterFormField, TemplateContext, WithCsrf, + WithOptionalSession, WithSession, }; /// Wrapper around [`tera::Tera`] helping rendering the various templates @@ -312,6 +312,12 @@ register_templates! { /// Render the HTML error page pub fn render_error(ErrorContext) { "pages/error.html" } + + /// Render the email verification email (plain text variant) + pub fn render_email_verification_txt(EmailVerificationContext) { "emails/verification.txt" } + + /// Render the email verification email (plain text variant) + pub fn render_email_verification_html(EmailVerificationContext) { "emails/verification.html" } } impl Templates { @@ -323,9 +329,12 @@ impl Templates { check::render_index(self).await?; check::render_account_index(self).await?; check::render_account_password(self).await?; + check::render_account_emails::<()>(self).await?; check::render_reauth(self).await?; check::render_form_post::(self).await?; check::render_error(self).await?; + check::render_email_verification_txt(self).await?; + check::render_email_verification_html(self).await?; Ok(()) } } diff --git a/crates/templates/src/res/emails/verification.html b/crates/templates/src/res/emails/verification.html new file mode 100644 index 00000000..3af448e4 --- /dev/null +++ b/crates/templates/src/res/emails/verification.html @@ -0,0 +1,23 @@ +{# +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +#} + +Hi {{ user.username }},
+
+click this link to verify your account:
+
+{{ verification_link }}
+
+kthxbye diff --git a/crates/templates/src/res/emails/verification.txt b/crates/templates/src/res/emails/verification.txt new file mode 100644 index 00000000..81f8ec77 --- /dev/null +++ b/crates/templates/src/res/emails/verification.txt @@ -0,0 +1,23 @@ +{# +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +#} + +Hi {{ user.username }}, + +click this link to verify your account: + +<{{ verification_link }}> + +kthxbye