From 93cbad34f512b965409057221fa95bd61516e571 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 19 Jan 2022 18:00:45 +0100 Subject: [PATCH] Actually send emails --- Cargo.lock | 8 ++ crates/cli/Cargo.toml | 3 +- crates/cli/src/server.rs | 14 ++- crates/config/Cargo.toml | 1 + crates/config/src/email.rs | 100 +++++++++++++++ crates/config/src/lib.rs | 7 ++ crates/email/Cargo.toml | 8 +- crates/email/src/lib.rs | 132 ++++++++++++++++++-- crates/handlers/Cargo.toml | 10 +- crates/handlers/src/lib.rs | 3 + crates/handlers/src/views/account/emails.rs | 34 ++++- crates/handlers/src/views/account/mod.rs | 4 +- crates/handlers/src/views/mod.rs | 4 +- 13 files changed, 301 insertions(+), 27 deletions(-) create mode 100644 crates/config/src/email.rs diff --git a/Cargo.lock b/Cargo.lock index 8cd0f87a..4f934136 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1472,11 +1472,13 @@ dependencies = [ "httpdate", "idna", "mime", + "nom 7.1.0", "once_cell", "quoted_printable", "regex", "rustls 0.20.2", "rustls-pemfile", + "serde", "tokio", "tokio-rustls 0.23.2", "tracing", @@ -1537,6 +1539,7 @@ dependencies = [ "hyper", "indoc", "mas-config", + "mas-email", "mas-handlers", "mas-storage", "mas-tasks", @@ -1572,6 +1575,7 @@ dependencies = [ "elliptic-curve", "figment", "indoc", + "lettre", "mas-jose", "p256", "pkcs8", @@ -1609,8 +1613,10 @@ dependencies = [ "anyhow", "async-trait", "lettre", + "mas-config", "mas-templates", "tokio", + "tracing", ] [[package]] @@ -1627,8 +1633,10 @@ dependencies = [ "headers", "hyper", "indoc", + "lettre", "mas-config", "mas-data-model", + "mas-email", "mas-iana", "mas-jose", "mas-static-files", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 133827a9..6abec7a7 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -34,9 +34,10 @@ opentelemetry-zipkin = { version = "0.14.0", features = ["reqwest-client", "reqw mas-config = { path = "../config" } mas-handlers = { path = "../handlers" } -mas-templates = { path = "../templates" } +mas-email = { path = "../email" } mas-storage = { path = "../storage" } mas-tasks = { path = "../tasks" } +mas-templates = { path = "../templates" } mas-warp-utils = { path = "../warp-utils" } [dev-dependencies] diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index bcc65a96..9520ac6c 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -23,6 +23,7 @@ use clap::Parser; use futures::{future::TryFutureExt, stream::TryStreamExt}; use hyper::{header, Server, Version}; use mas_config::RootConfig; +use mas_email::{MailTransport, Mailer}; use mas_storage::MIGRATOR; use mas_tasks::TaskQueue; use mas_templates::Templates; @@ -221,6 +222,10 @@ impl ServerCommand { .context("could not parse listener address")?; let listener = TcpListener::bind(addr).context("could not bind address")?; + // Connect to the mail server + let mail_transport = MailTransport::try_from(&config.email.transport)?; + mail_transport.test_connection().await?; + // Connect to the database let pool = config.database.connect().await?; @@ -250,6 +255,13 @@ impl ServerCommand { .await .context("could not load templates")?; + let mailer = Mailer::new( + &templates, + &mail_transport, + &config.email.from, + &config.email.reply_to, + ); + // Watch for changes in templates if the --watch flag is present if self.watch { let client = watchman_client::Connector::new() @@ -263,7 +275,7 @@ impl ServerCommand { } // Start the server - let root = mas_handlers::root(&pool, &templates, &key_store, &config); + let root = mas_handlers::root(&pool, &templates, &key_store, &mailer, &config); let warp_service = warp::service(root); diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index ffa008c8..f1ee8658 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -22,6 +22,7 @@ serde = { version = "1.0.133", features = ["derive"] } serde_with = { version = "1.11.0", features = ["hex", "chrono"] } serde_json = "1.0.74" sqlx = { version = "0.5.10", features = ["runtime-tokio-rustls", "postgres"] } +lettre = { version = "0.10.0-rc.4", default-features = false, features = ["serde", "builder"] } rand = "0.8.4" rsa = { git = "https://github.com/sandhose/rsa.git", branch = "bump-pkcs" } diff --git a/crates/config/src/email.rs b/crates/config/src/email.rs new file mode 100644 index 00000000..4411aa9f --- /dev/null +++ b/crates/config/src/email.rs @@ -0,0 +1,100 @@ +// 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 async_trait::async_trait; +use lettre::{message::Mailbox, Address}; +use schemars::{gen::SchemaGenerator, schema::Schema, JsonSchema}; +use serde::{Deserialize, Serialize}; + +use super::ConfigurationSection; + +fn mailbox_schema(gen: &mut SchemaGenerator) -> Schema { + // TODO: proper email schema + String::json_schema(gen) +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct Credentials { + pub username: String, + pub password: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum EmailSmtpMode { + Plain, + StartTls, + Tls, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "transport", rename_all = "lowercase")] +pub enum EmailTransportConfig { + Blackhole, + Smtp { + mode: EmailSmtpMode, + hostname: String, + + #[serde(default)] + port: Option, + + #[serde(flatten, default)] + credentials: Option, + }, +} + +impl Default for EmailTransportConfig { + fn default() -> Self { + Self::Blackhole + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct EmailConfig { + #[schemars(schema_with = "mailbox_schema")] + pub from: Mailbox, + + #[schemars(schema_with = "mailbox_schema")] + pub reply_to: Mailbox, + + #[serde(flatten)] + pub transport: EmailTransportConfig, +} + +impl Default for EmailConfig { + fn default() -> Self { + let address = Address::new("root", "localhost").unwrap(); + let mailbox = Mailbox::new(Some("Authentication Service".to_string()), address); + Self { + from: mailbox.clone(), + reply_to: mailbox, + transport: EmailTransportConfig::Blackhole, + } + } +} + +#[async_trait] +impl ConfigurationSection<'_> for EmailConfig { + fn path() -> &'static str { + "email" + } + + async fn generate() -> anyhow::Result { + Ok(Self::default()) + } + + fn test() -> Self { + Self::default() + } +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 17ccef09..389b1de6 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -27,6 +27,7 @@ use serde::{Deserialize, Serialize}; mod cookies; mod csrf; mod database; +mod email; mod http; mod oauth2; mod telemetry; @@ -37,6 +38,7 @@ pub use self::{ cookies::CookiesConfig, csrf::CsrfConfig, database::DatabaseConfig, + email::{EmailConfig, EmailSmtpMode, EmailTransportConfig}, http::HttpConfig, oauth2::{OAuth2ClientAuthMethodConfig, OAuth2ClientConfig, OAuth2Config}, telemetry::{ @@ -67,6 +69,9 @@ pub struct RootConfig { #[serde(default)] pub csrf: CsrfConfig, + + #[serde(default)] + pub email: EmailConfig, } #[async_trait] @@ -84,6 +89,7 @@ impl ConfigurationSection<'_> for RootConfig { telemetry: TelemetryConfig::generate().await?, templates: TemplatesConfig::generate().await?, csrf: CsrfConfig::generate().await?, + email: EmailConfig::generate().await?, }) } @@ -96,6 +102,7 @@ impl ConfigurationSection<'_> for RootConfig { telemetry: TelemetryConfig::test(), templates: TemplatesConfig::test(), csrf: CsrfConfig::test(), + email: EmailConfig::test(), } } } diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml index 48df385e..d425422b 100644 --- a/crates/email/Cargo.toml +++ b/crates/email/Cargo.toml @@ -6,13 +6,15 @@ edition = "2021" license = "Apache-2.0" [dependencies] +anyhow = "1.0.52" +async-trait = "0.1.52" tokio = { version = "1.15.0", features = ["macros"] } mas-templates = { path = "../templates" } -anyhow = "1.0.52" -async-trait = "0.1.52" +mas-config = { path = "../config" } +tracing = "0.1.29" [dependencies.lettre] version = "0.10.0-rc.4" default-features = false -features = ["tokio1-rustls-tls", "hostname", "builder", "tracing", "pool"] +features = ["tokio1-rustls-tls", "hostname", "builder", "tracing", "pool", "smtp-transport"] diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index b47f2b52..da6cd362 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -12,24 +12,130 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::sync::Arc; + +use async_trait::async_trait; use lettre::{ + address::Envelope, message::{Mailbox, MessageBuilder, MultiPart}, - AsyncTransport, Message, + transport::smtp::{authentication::Credentials, AsyncSmtpTransport}, + AsyncTransport, Message, Tokio1Executor, }; +use mas_config::{EmailSmtpMode, EmailTransportConfig}; use mas_templates::{EmailVerificationContext, Templates}; +#[derive(Default, Clone)] +pub struct MailTransport { + inner: Arc, +} + +enum MailTransportInner { + Blackhole, + Smtp(AsyncSmtpTransport), +} + +impl TryFrom<&EmailTransportConfig> for MailTransport { + type Error = anyhow::Error; + + fn try_from(config: &EmailTransportConfig) -> Result { + let inner = match config { + EmailTransportConfig::Blackhole => MailTransportInner::Blackhole, + EmailTransportConfig::Smtp { + mode, + hostname, + credentials, + port, + } => { + let mut t = match mode { + EmailSmtpMode::Plain => { + AsyncSmtpTransport::::builder_dangerous(hostname) + } + EmailSmtpMode::StartTls => { + AsyncSmtpTransport::::starttls_relay(hostname)? + } + EmailSmtpMode::Tls => AsyncSmtpTransport::::relay(hostname)?, + }; + + if let Some(credentials) = credentials { + t = t.credentials(Credentials::new( + credentials.username.clone(), + credentials.password.clone(), + )); + } + + if let Some(port) = port { + t = t.port(*port); + } + + MailTransportInner::Smtp(t.build()) + } + }; + let inner = Arc::new(inner); + Ok(Self { inner }) + } +} + +impl MailTransport { + pub async fn test_connection(&self) -> anyhow::Result<()> { + match self.inner.as_ref() { + MailTransportInner::Blackhole => {} + MailTransportInner::Smtp(t) => { + t.test_connection().await?; + } + } + + Ok(()) + } +} + +impl Default for MailTransportInner { + fn default() -> Self { + Self::Blackhole + } +} + +#[async_trait] +impl AsyncTransport for MailTransport { + type Ok = (); + type Error = anyhow::Error; + + async fn send_raw(&self, envelope: &Envelope, email: &[u8]) -> Result { + match self.inner.as_ref() { + MailTransportInner::Blackhole => { + tracing::warn!( + ?envelope, + "An email was supposed to be sent but no email backend is configured" + ); + } + MailTransportInner::Smtp(t) => { + t.send_raw(envelope, email).await?; + } + }; + + Ok(()) + } +} + +#[derive(Clone)] pub struct Mailer { templates: Templates, + transport: MailTransport, from: Mailbox, reply_to: Mailbox, } impl Mailer { - pub fn new(templates: &Templates, from: Mailbox, reply_to: Mailbox) -> Self { + pub fn new( + templates: &Templates, + transport: &MailTransport, + from: &Mailbox, + reply_to: &Mailbox, + ) -> Self { Self { templates: templates.clone(), - from, - reply_to, + transport: transport.clone(), + from: from.clone(), + reply_to: reply_to.clone(), } } @@ -56,23 +162,23 @@ impl Mailer { let multipart = MultiPart::alternative_plain_html(plain, html); - let message = self.base_message().to(to).multipart(multipart)?; + let message = self + .base_message() + // TODO: template/localize this + .subject("Verify your email address") + .to(to) + .multipart(multipart)?; Ok(message) } - pub async fn send_verification_email( + 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, - { + ) -> anyhow::Result<()> { let message = self.prepare_verification_email(to, context).await?; - transport.send(message).await?; + self.transport.send(message).await?; Ok(()) } } diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 04504a61..3b73278b 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -23,6 +23,9 @@ anyhow = "1.0.52" warp = "0.3.2" hyper = { version = "0.14.16", features = ["full"] } +# Emails +lettre = { version = "0.10.0-rc.4", default-features = false, features = ["builder"] } + # Database access sqlx = { version = "0.5.10", features = ["runtime-tokio-rustls", "postgres"] } @@ -54,12 +57,13 @@ headers = "0.3.5" oauth2-types = { path = "../oauth2-types" } mas-config = { path = "../config" } mas-data-model = { path = "../data-model" } -mas-templates = { path = "../templates" } +mas-email = { path = "../email" } +mas-iana = { path = "../iana" } +mas-jose = { path = "../jose" } mas-static-files = { path = "../static-files" } mas-storage = { path = "../storage" } +mas-templates = { path = "../templates" } mas-warp-utils = { path = "../warp-utils" } -mas-jose = { path = "../jose" } -mas-iana = { path = "../iana" } [dev-dependencies] indoc = "1.0.3" diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 7fadc75b..2a0474f3 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -25,6 +25,7 @@ use std::sync::Arc; use mas_config::RootConfig; +use mas_email::Mailer; use mas_jose::StaticKeystore; use mas_static_files::filter as static_files; use mas_templates::Templates; @@ -42,6 +43,7 @@ pub fn root( pool: &PgPool, templates: &Templates, key_store: &Arc, + mailer: &Mailer, config: &RootConfig, ) -> BoxedFilter<(impl Reply,)> { let health = health(pool); @@ -49,6 +51,7 @@ pub fn root( let views = views( pool, templates, + mailer, &config.oauth2, &config.csrf, &config.cookies, diff --git a/crates/handlers/src/views/account/emails.rs b/crates/handlers/src/views/account/emails.rs index a34ce4c4..7a6abdd2 100644 --- a/crates/handlers/src/views/account/emails.rs +++ b/crates/handlers/src/views/account/emails.rs @@ -12,8 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +use lettre::{message::Mailbox, Address}; use mas_config::{CookiesConfig, CsrfConfig}; use mas_data_model::BrowserSession; +use mas_email::Mailer; use mas_storage::{ user::{ add_user_email, get_user_email, get_user_emails, remove_user_email, @@ -21,7 +23,7 @@ use mas_storage::{ }, PostgresqlBackend, }; -use mas_templates::{AccountEmailsContext, TemplateContext, Templates}; +use mas_templates::{AccountEmailsContext, EmailVerificationContext, TemplateContext, Templates}; use mas_warp_utils::{ errors::WrapError, filters::{ @@ -35,14 +37,18 @@ use mas_warp_utils::{ use serde::Deserialize; use sqlx::{pool::PoolConnection, PgExecutor, PgPool, Postgres, Transaction}; use tracing::info; +use url::Url; use warp::{filters::BoxedFilter, reply::html, Filter, Rejection, Reply}; pub(super) fn filter( pool: &PgPool, templates: &Templates, + mailer: &Mailer, csrf_config: &CsrfConfig, cookies_config: &CookiesConfig, ) -> BoxedFilter<(Box,)> { + let mailer = mailer.clone(); + let get = with_templates(templates) .and(encrypted_cookie_saver(cookies_config)) .and(updated_csrf_token(cookies_config, csrf_config)) @@ -51,6 +57,7 @@ pub(super) fn filter( .and_then(get); let post = with_templates(templates) + .and(warp::any().map(move || mailer.clone())) .and(encrypted_cookie_saver(cookies_config)) .and(updated_csrf_token(cookies_config, csrf_config)) .and(session(pool, cookies_config)) @@ -108,6 +115,7 @@ async fn render( async fn post( templates: Templates, + mailer: Mailer, cookie_saver: EncryptedCookieSaver, csrf_token: CsrfToken, mut session: BrowserSession, @@ -131,10 +139,28 @@ async fn post( } Form::ResendConfirmation { data } => { let id: i64 = data.parse().wrap_error()?; - info!( - email.id = id, - "Not implemented yet: re-send confirmation email" + + let email: Address = get_user_email(&mut txn, &session.user, id) + .await + .wrap_error()? + .email + .parse() + .wrap_error()?; + + let mailbox = Mailbox::new(Some(session.user.username.clone()), email); + + // TODO: actually generate a verification link + let context = EmailVerificationContext::new( + session.user.clone().into(), + Url::parse("https://example.com/verify").unwrap(), ); + + mailer + .send_verification_email(mailbox, &context) + .await + .wrap_error()?; + + info!(email.id = id, "Verification email sent"); } Form::SetPrimary { data } => { let id = data.parse().wrap_error()?; diff --git a/crates/handlers/src/views/account/mod.rs b/crates/handlers/src/views/account/mod.rs index a179caf2..74989daf 100644 --- a/crates/handlers/src/views/account/mod.rs +++ b/crates/handlers/src/views/account/mod.rs @@ -17,6 +17,7 @@ mod password; use mas_config::{CookiesConfig, CsrfConfig}; use mas_data_model::BrowserSession; +use mas_email::Mailer; use mas_storage::{ user::{count_active_sessions, get_user_emails}, PostgresqlBackend, @@ -40,6 +41,7 @@ use self::{emails::filter as emails, password::filter as password}; pub(super) fn filter( pool: &PgPool, templates: &Templates, + mailer: &Mailer, csrf_config: &CsrfConfig, cookies_config: &CookiesConfig, ) -> BoxedFilter<(Box,)> { @@ -53,7 +55,7 @@ pub(super) fn filter( let index = warp::path::end().and(get); let password = password(pool, templates, csrf_config, cookies_config); - let emails = emails(pool, templates, csrf_config, cookies_config); + let emails = emails(pool, templates, mailer, csrf_config, cookies_config); let filter = index.or(password).unify().or(emails).unify(); diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index 6f68f278..8a1c22de 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -13,6 +13,7 @@ // limitations under the License. use mas_config::{CookiesConfig, CsrfConfig, OAuth2Config}; +use mas_email::Mailer; use mas_templates::Templates; use sqlx::PgPool; use warp::{filters::BoxedFilter, Filter, Reply}; @@ -36,12 +37,13 @@ pub(crate) use self::{ pub(super) fn filter( pool: &PgPool, templates: &Templates, + mailer: &Mailer, oauth2_config: &OAuth2Config, csrf_config: &CsrfConfig, cookies_config: &CookiesConfig, ) -> BoxedFilter<(Box,)> { let index = index(pool, templates, oauth2_config, csrf_config, cookies_config); - let account = account(pool, templates, csrf_config, cookies_config); + let account = account(pool, templates, mailer, csrf_config, cookies_config); let login = login(pool, templates, csrf_config, cookies_config); let register = register(pool, templates, csrf_config, cookies_config); let logout = logout(pool, cookies_config);