1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-31 09:24:31 +03:00

Actually send emails

This commit is contained in:
Quentin Gliech
2022-01-19 18:00:45 +01:00
parent 2e12f43c0e
commit 93cbad34f5
13 changed files with 301 additions and 27 deletions

8
Cargo.lock generated
View File

@ -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",

View File

@ -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]

View File

@ -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);

View File

@ -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" }

100
crates/config/src/email.rs Normal file
View File

@ -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<u16>,
#[serde(flatten, default)]
credentials: Option<Credentials>,
},
}
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<Self> {
Ok(Self::default())
}
fn test() -> Self {
Self::default()
}
}

View File

@ -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(),
}
}
}

View File

@ -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"]

View File

@ -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<MailTransportInner>,
}
enum MailTransportInner {
Blackhole,
Smtp(AsyncSmtpTransport<Tokio1Executor>),
}
impl TryFrom<&EmailTransportConfig> for MailTransport {
type Error = anyhow::Error;
fn try_from(config: &EmailTransportConfig) -> Result<Self, Self::Error> {
let inner = match config {
EmailTransportConfig::Blackhole => MailTransportInner::Blackhole,
EmailTransportConfig::Smtp {
mode,
hostname,
credentials,
port,
} => {
let mut t = match mode {
EmailSmtpMode::Plain => {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(hostname)
}
EmailSmtpMode::StartTls => {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(hostname)?
}
EmailSmtpMode::Tls => AsyncSmtpTransport::<Tokio1Executor>::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<Self::Ok, Self::Error> {
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<T>(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<T>(
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(())
}
}

View File

@ -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"

View File

@ -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<StaticKeystore>,
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,

View File

@ -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<dyn Reply>,)> {
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<PostgresqlBackend>,
@ -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()?;

View File

@ -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<dyn Reply>,)> {
@ -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();

View File

@ -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<dyn Reply>,)> {
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);