You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-07-29 22:01:14 +03:00
Actually send emails
This commit is contained in:
@ -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]
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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
100
crates/config/src/email.rs
Normal 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()
|
||||
}
|
||||
}
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"]
|
||||
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
|
@ -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()?;
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
|
Reference in New Issue
Block a user