mas_config/sections/
email.rs

1// Copyright 2024, 2025 New Vector Ltd.
2// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7#![allow(deprecated)]
8
9use std::{num::NonZeroU16, str::FromStr};
10
11use lettre::message::Mailbox;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize, de::Error};
14
15use super::ConfigurationSection;
16
17/// Encryption mode to use
18#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "lowercase")]
20pub enum EmailSmtpMode {
21    /// Plain text
22    Plain,
23
24    /// `StartTLS` (starts as plain text then upgrade to TLS)
25    StartTls,
26
27    /// TLS
28    Tls,
29}
30
31/// What backend should be used when sending emails
32#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Default)]
33#[serde(rename_all = "snake_case")]
34pub enum EmailTransportKind {
35    /// Don't send emails anywhere
36    #[default]
37    Blackhole,
38
39    /// Send emails via an SMTP relay
40    Smtp,
41
42    /// Send emails by calling sendmail
43    Sendmail,
44}
45
46fn default_email() -> String {
47    r#""Authentication Service" <root@localhost>"#.to_owned()
48}
49
50#[allow(clippy::unnecessary_wraps)]
51fn default_sendmail_command() -> Option<String> {
52    Some("sendmail".to_owned())
53}
54
55/// Configuration related to sending emails
56#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
57pub struct EmailConfig {
58    /// Email address to use as From when sending emails
59    #[serde(default = "default_email")]
60    #[schemars(email)]
61    pub from: String,
62
63    /// Email address to use as Reply-To when sending emails
64    #[serde(default = "default_email")]
65    #[schemars(email)]
66    pub reply_to: String,
67
68    /// What backend should be used when sending emails
69    transport: EmailTransportKind,
70
71    /// SMTP transport: Connection mode to the relay
72    #[serde(skip_serializing_if = "Option::is_none")]
73    mode: Option<EmailSmtpMode>,
74
75    /// SMTP transport: Hostname to connect to
76    #[serde(skip_serializing_if = "Option::is_none")]
77    #[schemars(with = "Option<crate::schema::Hostname>")]
78    hostname: Option<String>,
79
80    /// SMTP transport: Port to connect to. Default is 25 for plain, 465 for TLS
81    /// and 587 for `StartTLS`
82    #[serde(skip_serializing_if = "Option::is_none")]
83    #[schemars(range(min = 1, max = 65535))]
84    port: Option<NonZeroU16>,
85
86    /// SMTP transport: Username for use to authenticate when connecting to the
87    /// SMTP server
88    ///
89    /// Must be set if the `password` field is set
90    #[serde(skip_serializing_if = "Option::is_none")]
91    username: Option<String>,
92
93    /// SMTP transport: Password for use to authenticate when connecting to the
94    /// SMTP server
95    ///
96    /// Must be set if the `username` field is set
97    #[serde(skip_serializing_if = "Option::is_none")]
98    password: Option<String>,
99
100    /// Sendmail transport: Command to use to send emails
101    #[serde(skip_serializing_if = "Option::is_none")]
102    #[schemars(default = "default_sendmail_command")]
103    command: Option<String>,
104}
105
106impl EmailConfig {
107    /// What backend should be used when sending emails
108    #[must_use]
109    pub fn transport(&self) -> EmailTransportKind {
110        self.transport
111    }
112
113    /// Connection mode to the relay
114    #[must_use]
115    pub fn mode(&self) -> Option<EmailSmtpMode> {
116        self.mode
117    }
118
119    /// Hostname to connect to
120    #[must_use]
121    pub fn hostname(&self) -> Option<&str> {
122        self.hostname.as_deref()
123    }
124
125    /// Port to connect to
126    #[must_use]
127    pub fn port(&self) -> Option<NonZeroU16> {
128        self.port
129    }
130
131    /// Username for use to authenticate when connecting to the SMTP server
132    #[must_use]
133    pub fn username(&self) -> Option<&str> {
134        self.username.as_deref()
135    }
136
137    /// Password for use to authenticate when connecting to the SMTP server
138    #[must_use]
139    pub fn password(&self) -> Option<&str> {
140        self.password.as_deref()
141    }
142
143    /// Command to use to send emails
144    #[must_use]
145    pub fn command(&self) -> Option<&str> {
146        self.command.as_deref()
147    }
148}
149
150impl Default for EmailConfig {
151    fn default() -> Self {
152        Self {
153            from: default_email(),
154            reply_to: default_email(),
155            transport: EmailTransportKind::Blackhole,
156            mode: None,
157            hostname: None,
158            port: None,
159            username: None,
160            password: None,
161            command: None,
162        }
163    }
164}
165
166impl ConfigurationSection for EmailConfig {
167    const PATH: Option<&'static str> = Some("email");
168
169    fn validate(
170        &self,
171        figment: &figment::Figment,
172    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
173        let metadata = figment.find_metadata(Self::PATH.unwrap());
174
175        let error_on_field = |mut error: figment::error::Error, field: &'static str| {
176            error.metadata = metadata.cloned();
177            error.profile = Some(figment::Profile::Default);
178            error.path = vec![Self::PATH.unwrap().to_owned(), field.to_owned()];
179            error
180        };
181
182        let missing_field = |field: &'static str| {
183            error_on_field(figment::error::Error::missing_field(field), field)
184        };
185
186        let unexpected_field = |field: &'static str, expected_fields: &'static [&'static str]| {
187            error_on_field(
188                figment::error::Error::unknown_field(field, expected_fields),
189                field,
190            )
191        };
192
193        match self.transport {
194            EmailTransportKind::Blackhole => {}
195
196            EmailTransportKind::Smtp => {
197                if let Err(e) = Mailbox::from_str(&self.from) {
198                    return Err(error_on_field(figment::error::Error::custom(e), "from").into());
199                }
200
201                if let Err(e) = Mailbox::from_str(&self.reply_to) {
202                    return Err(error_on_field(figment::error::Error::custom(e), "reply_to").into());
203                }
204
205                match (self.username.is_some(), self.password.is_some()) {
206                    (true, true) | (false, false) => {}
207                    (true, false) => {
208                        return Err(missing_field("password").into());
209                    }
210                    (false, true) => {
211                        return Err(missing_field("username").into());
212                    }
213                }
214
215                if self.mode.is_none() {
216                    return Err(missing_field("mode").into());
217                }
218
219                if self.hostname.is_none() {
220                    return Err(missing_field("hostname").into());
221                }
222
223                if self.command.is_some() {
224                    return Err(unexpected_field(
225                        "command",
226                        &[
227                            "from",
228                            "reply_to",
229                            "transport",
230                            "mode",
231                            "hostname",
232                            "port",
233                            "username",
234                            "password",
235                        ],
236                    )
237                    .into());
238                }
239            }
240
241            EmailTransportKind::Sendmail => {
242                let expected_fields = &["from", "reply_to", "transport", "command"];
243
244                if let Err(e) = Mailbox::from_str(&self.from) {
245                    return Err(error_on_field(figment::error::Error::custom(e), "from").into());
246                }
247
248                if let Err(e) = Mailbox::from_str(&self.reply_to) {
249                    return Err(error_on_field(figment::error::Error::custom(e), "reply_to").into());
250                }
251
252                if self.command.is_none() {
253                    return Err(missing_field("command").into());
254                }
255
256                if self.mode.is_some() {
257                    return Err(unexpected_field("mode", expected_fields).into());
258                }
259
260                if self.hostname.is_some() {
261                    return Err(unexpected_field("hostname", expected_fields).into());
262                }
263
264                if self.port.is_some() {
265                    return Err(unexpected_field("port", expected_fields).into());
266                }
267
268                if self.username.is_some() {
269                    return Err(unexpected_field("username", expected_fields).into());
270                }
271
272                if self.password.is_some() {
273                    return Err(unexpected_field("password", expected_fields).into());
274                }
275            }
276        }
277
278        Ok(())
279    }
280}