mas_config/sections/
email.rs

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