diff --git a/Cargo.lock b/Cargo.lock index 09424be62..cbf308352 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,6 +72,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -113,6 +119,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -820,6 +835,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -1034,6 +1059,7 @@ dependencies = [ "hyper-util", "ipaddress", "itertools 0.14.0", + "lettre", "libc", "libloading 0.9.0", "lock_api", @@ -1150,6 +1176,7 @@ dependencies = [ "ipaddress", "itertools 0.14.0", "ldap3", + "lettre", "log", "loole", "lru-cache", @@ -1757,6 +1784,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2228,6 +2271,16 @@ version = "0.1.2+12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "647deb1583b14d160f85f3ff626f20b6edd366e3852c9843b06077388f794cb6" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2899,6 +2952,37 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "rustls-native-certs", + "serde", + "socket2 0.6.3", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + [[package]] name = "libc" version = "0.2.183" @@ -4023,6 +4107,16 @@ dependencies = [ "prost", ] +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.13.1" @@ -4127,6 +4221,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -5271,6 +5371,20 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + [[package]] name = "strict" version = "0.2.0" @@ -6406,6 +6520,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 082c93102..8170dc86f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -556,6 +556,11 @@ version = "1.0.1" [workspace.dependencies.askama] version = "0.15.0" +[workspace.dependencies.lettre] +version = "0.11.19" +default-features = false +features = ["smtp-transport", "pool", "hostname", "builder", "rustls", "aws-lc-rs", "rustls-native-certs", "tokio1", "tokio1-rustls", "tracing", "serde"] + # # Patches # @@ -916,7 +921,6 @@ fn_to_numeric_cast_any = "warn" format_push_string = "warn" get_unwrap = "warn" impl_trait_in_params = "warn" -let_underscore_untyped = "warn" lossy_float_literal = "warn" mem_forget = "warn" missing_assert_message = "warn" diff --git a/conduwuit-example.toml b/conduwuit-example.toml index 4bbd916c5..2fa6b5c19 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2041,3 +2041,27 @@ # web->synapseHTTPAntispam->authorization # #secret = + +#[global.smtp] + +# A `smtp://`` URI which will be used to connect to a mail server. Setting +# this option enables features which depend on the ability to send email, +# such as self-service password resets. +# +# For most modern mail servers, format the URI like this: +# `smtps://username:password@hostname:port` +# +# For a guide on the accepted URI syntax, consult Lettre's documentation: +# https://docs.rs/lettre/latest/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url +# +#connection_uri = + +# The outgoing address which will be used for sending emails. +# +# For a syntax guide, see https://datatracker.ietf.org/doc/html/rfc2822#section-3.4 +# +# ...or if you don't want to read the RFC, for some reason: +# - `Name ` to specify a sender name +# - `address@domain.org` to not use a name +# +#sender = diff --git a/src/core/Cargo.toml b/src/core/Cargo.toml index 25498470b..7db2e4032 100644 --- a/src/core/Cargo.toml +++ b/src/core/Cargo.toml @@ -84,6 +84,7 @@ libc.workspace = true libloading.workspace = true libloading.optional = true log.workspace = true +lettre.workspace = true num-traits.workspace = true rand.workspace = true rand_core = { version = "0.6.4", features = ["getrandom"] } diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 936f57dd5..f9a3469af 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -16,6 +16,7 @@ use either::{ }; use figment::providers::{Env, Format, Toml}; pub use figment::{Figment, value::Value as FigmentValue}; +use lettre::message::Mailbox; use regex::RegexSet; use ruma::{ OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomVersionId, @@ -760,6 +761,9 @@ pub struct Config { #[serde(default)] pub well_known: WellKnownConfig, + /// display: nested + pub smtp: Option, + /// Enable OpenTelemetry OTLP tracing export. This replaces the deprecated /// Jaeger exporter. Traces will be sent via OTLP to a collector (such as /// Jaeger) that supports the OpenTelemetry Protocol. @@ -2444,6 +2448,34 @@ pub struct DraupnirConfig { pub secret: String, } +#[derive(Clone, Debug, Deserialize)] +#[config_example_generator( + filename = "conduwuit-example.toml", + section = "global.smtp", + optional = "true" +)] +pub struct SmtpConfig { + /// A `smtp://`` URI which will be used to connect to a mail server. Setting + /// this option enables features which depend on the ability to send email, + /// such as self-service password resets. + /// + /// For most modern mail servers, format the URI like this: + /// `smtps://username:password@hostname:port` + /// + /// For a guide on the accepted URI syntax, consult Lettre's documentation: + /// https://docs.rs/lettre/latest/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url + pub connection_uri: String, + + /// The outgoing address which will be used for sending emails. + /// + /// For a syntax guide, see https://datatracker.ietf.org/doc/html/rfc2822#section-3.4 + /// + /// ...or if you don't want to read the RFC, for some reason: + /// - `Name ` to specify a sender name + /// - `address@domain.org` to not use a name + pub sender: Mailbox, +} + const DEPRECATED_KEYS: &[&str] = &[ "cache_capacity", "conduit_cache_capacity_modifier", diff --git a/src/service/Cargo.toml b/src/service/Cargo.toml index 4d164e1bf..533def8f2 100644 --- a/src/service/Cargo.toml +++ b/src/service/Cargo.toml @@ -123,6 +123,7 @@ blurhash.workspace = true blurhash.optional = true recaptcha-verify = { version = "0.2.0", default-features = false } yansi.workspace = true +lettre.workspace = true [target.'cfg(all(unix, target_os = "linux"))'.dependencies] sd-notify.workspace = true diff --git a/src/service/mailer/messages.rs b/src/service/mailer/messages.rs new file mode 100644 index 000000000..278d3c0e0 --- /dev/null +++ b/src/service/mailer/messages.rs @@ -0,0 +1,48 @@ +use askama::Template; +use ruma::UserId; + +pub trait MessageTemplate: Template { + fn subject(&self) -> String; +} + +#[derive(Template)] +#[template(path = "mail/change_email.txt.j2")] +pub struct ChangeEmail<'a> { + user_id: &'a UserId, + verification_link: &'a str, +} + +impl MessageTemplate for ChangeEmail<'_> { + fn subject(&self) -> String { "Verify your email address".to_owned() } +} + +#[derive(Template)] +#[template(path = "mail/new_account.txt.j2")] +pub struct NewAccount<'a> { + server_name: &'a str, + verification_link: &'a str, +} + +impl MessageTemplate for NewAccount<'_> { + fn subject(&self) -> String { "Create your new Matrix account".to_owned() } +} + +#[derive(Template)] +#[template(path = "mail/password_reset.txt.j2")] +pub struct PasswordReset<'a> { + display_name: &'a str, + user_id: &'a UserId, + verification_link: &'a str, +} + +impl MessageTemplate for PasswordReset<'_> { + fn subject(&self) -> String { format!("Password reset request for {}", &self.user_id) } +} + +#[derive(Template)] +#[template(path = "mail/test.txt.j2")] +pub struct Test; + +impl MessageTemplate for Test { + fn subject(&self) -> String { "Test message".to_owned() } +} diff --git a/src/service/mailer/mod.rs b/src/service/mailer/mod.rs new file mode 100644 index 000000000..d43e05e28 --- /dev/null +++ b/src/service/mailer/mod.rs @@ -0,0 +1,102 @@ +use std::sync::Arc; + +use conduwuit::{Err, Result, err, info}; +use lettre::{ + AsyncSmtpTransport, AsyncTransport, Tokio1Executor, + message::{Mailbox, MessageBuilder}, +}; + +use crate::{Args, mailer::messages::MessageTemplate}; + +pub mod messages; + +type Transport = AsyncSmtpTransport; +type TransportError = lettre::transport::smtp::Error; + +pub struct Service { + transport: Option<(Mailbox, Transport)>, +} + +#[async_trait::async_trait] +impl crate::Service for Service { + fn build(args: Args<'_>) -> Result> { + let transport = args + .server + .config + .smtp + .as_ref() + .map(|config| { + Ok((config.sender.clone(), Transport::from_url(&config.connection_uri)?.build())) + }) + .transpose() + .map_err(|err: TransportError| err!("Failed to set up SMTP transport: {err}"))?; + + Ok(Arc::new(Self { transport })) + } + + fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } + + async fn worker(self: Arc) -> Result<()> { + if let Some((_, ref transport)) = self.transport { + match transport.test_connection().await { + | Ok(true) => { + info!("SMTP connection test successful"); + Ok(()) + }, + | Ok(false) => { + Err!("SMTP connection test failed") + }, + | Err(err) => { + Err!("SMTP connection test failed: {err}") + }, + } + } else { + info!("SMTP is not configured, email functionality will be unavailable"); + Ok(()) + } + } +} + +impl Service { + /// Returns a mailer which allows email to be sent, if SMTP is configured. + #[must_use] + pub fn mailer(&self) -> Option> { + self.transport + .as_ref() + .map(|(sender, transport)| Mailer { sender, transport }) + } +} + +pub struct Mailer<'a> { + sender: &'a Mailbox, + transport: &'a Transport, +} + +impl Mailer<'_> { + /// Sends an email. + pub async fn send( + &self, + recipient: Mailbox, + message: Template, + ) -> Result<()> { + let subject = message.subject(); + let body = message + .render() + .map_err(|err| err!("Failed to render message template: {err}"))?; + + let message = MessageBuilder::new() + .from(self.sender.clone()) + .to(recipient) + .subject(subject) + .date_now() + .body(body) + .expect("should have been able to construct message"); + + self.transport + .send(message) + .await + .map_err(|err: TransportError| err!("Failed to send message: {err}"))?; + + Ok(()) + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs index 5480b8383..0b14667e3 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -21,6 +21,7 @@ pub mod federation; pub mod firstrun; pub mod globals; pub mod key_backups; +pub mod mailer; pub mod media; pub mod moderation; pub mod password_reset; diff --git a/src/service/services.rs b/src/service/services.rs index 1a1510f71..a162c61a1 100644 --- a/src/service/services.rs +++ b/src/service/services.rs @@ -9,7 +9,7 @@ use tokio::sync::Mutex; use crate::{ account_data, admin, announcements, antispam, appservice, client, config, emergency, - federation, firstrun, globals, key_backups, + federation, firstrun, globals, key_backups, mailer, manager::Manager, media, moderation, password_reset, presence, pusher, registration_tokens, resolver, rooms, sending, server_keys, @@ -28,6 +28,7 @@ pub struct Services { pub key_backups: Arc, pub media: Arc, pub password_reset: Arc, + pub mailer: Arc, pub presence: Arc, pub pusher: Arc, pub registration_tokens: Arc, @@ -83,6 +84,7 @@ impl Services { key_backups: build!(key_backups::Service), media: build!(media::Service), password_reset: build!(password_reset::Service), + mailer: build!(mailer::Service), presence: build!(presence::Service), pusher: build!(pusher::Service), registration_tokens: build!(registration_tokens::Service), diff --git a/src/service/templates/mail/_base.txt.j2 b/src/service/templates/mail/_base.txt.j2 new file mode 100644 index 000000000..e64a811b0 --- /dev/null +++ b/src/service/templates/mail/_base.txt.j2 @@ -0,0 +1,3 @@ +{%- block content %}{% endblock %} + +Message sent by Continuwuity {{ env!("CARGO_PKG_VERSION") }}. 🐈 diff --git a/src/service/templates/mail/change_email.txt.j2 b/src/service/templates/mail/change_email.txt.j2 new file mode 100644 index 000000000..8168e7c60 --- /dev/null +++ b/src/service/templates/mail/change_email.txt.j2 @@ -0,0 +1,10 @@ +{% extends "_base.txt.j2" %} + +{% block content -%} +Hello! + +Somebody, probably you, tried to associate this email address with the Matrix account {{ user_id }}. +If that's your account, and this is your email address, click this link to proceed: + {{ verification_link }} +Otherwise, you can ignore this email. The above link will expire in one hour. +{%- endblock %} diff --git a/src/service/templates/mail/new_account.txt.j2 b/src/service/templates/mail/new_account.txt.j2 new file mode 100644 index 000000000..258390b30 --- /dev/null +++ b/src/service/templates/mail/new_account.txt.j2 @@ -0,0 +1,10 @@ +{% extends "_base.txt.j2" %} + +{% block content -%} +Hello! + +Somebody, probably you, tried to create a Matrix account on {{ server_name }} using this email address. +Use the link below to proceed with creating your account: + {{ verification_link }} +If you are not trying to create an account, you can ignore this email. The above link will expire in one hour. +{%- endblock %} diff --git a/src/service/templates/mail/password_reset.txt.j2 b/src/service/templates/mail/password_reset.txt.j2 new file mode 100644 index 000000000..46ce558db --- /dev/null +++ b/src/service/templates/mail/password_reset.txt.j2 @@ -0,0 +1,10 @@ +{% extends "_base.txt.j2" %} + +{% block content -%} +Hello {{ display_name }} ({{ user_id }}), + +Somebody, probably you, tried to reset your Matrix account's password. +If you requested for your password to be reset, click this link to proceed: + {{ verification_link }} +Otherwise, you can ignore this email. The above link will expire in one hour. +{%- endblock %} diff --git a/src/service/templates/mail/test.txt.j2 b/src/service/templates/mail/test.txt.j2 new file mode 100644 index 000000000..9bd09868e --- /dev/null +++ b/src/service/templates/mail/test.txt.j2 @@ -0,0 +1,5 @@ +{% extends "_base.txt.j2" %} + +{% block content -%} +If you're seeing this, SMTP is configured correctly. :3 +{%- endblock %}