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 %}