1#![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#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "lowercase")]
21pub enum EmailSmtpMode {
22 Plain,
24
25 StartTls,
27
28 Tls,
30}
31
32#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Default)]
34#[serde(rename_all = "snake_case")]
35pub enum EmailTransportKind {
36 #[default]
38 Blackhole,
39
40 Smtp,
42
43 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#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
58pub struct EmailConfig {
59 #[serde(default = "default_email")]
61 #[schemars(email)]
62 pub from: String,
63
64 #[serde(default = "default_email")]
66 #[schemars(email)]
67 pub reply_to: String,
68
69 transport: EmailTransportKind,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 mode: Option<EmailSmtpMode>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 #[schemars(with = "Option<crate::schema::Hostname>")]
79 hostname: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
84 #[schemars(range(min = 1, max = 65535))]
85 port: Option<NonZeroU16>,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
92 username: Option<String>,
93
94 #[serde(skip_serializing_if = "Option::is_none")]
99 password: Option<String>,
100
101 #[serde(skip_serializing_if = "Option::is_none")]
106 #[schemars(with = "Option<String>")]
107 password_file: Option<Utf8PathBuf>,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
111 #[schemars(default = "default_sendmail_command")]
112 command: Option<String>,
113}
114
115impl EmailConfig {
116 #[must_use]
118 pub fn transport(&self) -> EmailTransportKind {
119 self.transport
120 }
121
122 #[must_use]
124 pub fn mode(&self) -> Option<EmailSmtpMode> {
125 self.mode
126 }
127
128 #[must_use]
130 pub fn hostname(&self) -> Option<&str> {
131 self.hostname.as_deref()
132 }
133
134 #[must_use]
136 pub fn port(&self) -> Option<NonZeroU16> {
137 self.port
138 }
139
140 #[must_use]
142 pub fn username(&self) -> Option<&str> {
143 self.username.as_deref()
144 }
145
146 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 #[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}