Skip to main content

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