1#![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 pub username: String,
21
22 pub password: String,
24}
25
26#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "lowercase")]
29pub enum EmailSmtpMode {
30 Plain,
32
33 StartTls,
35
36 Tls,
38}
39
40#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Default)]
42#[serde(rename_all = "snake_case")]
43pub enum EmailTransportKind {
44 #[default]
46 Blackhole,
47
48 Smtp,
50
51 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#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
66pub struct EmailConfig {
67 #[serde(default = "default_email")]
69 #[schemars(email)]
70 pub from: String,
71
72 #[serde(default = "default_email")]
74 #[schemars(email)]
75 pub reply_to: String,
76
77 transport: EmailTransportKind,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
82 mode: Option<EmailSmtpMode>,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
86 #[schemars(with = "Option<crate::schema::Hostname>")]
87 hostname: Option<String>,
88
89 #[serde(skip_serializing_if = "Option::is_none")]
92 #[schemars(range(min = 1, max = 65535))]
93 port: Option<NonZeroU16>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
100 username: Option<String>,
101
102 #[serde(skip_serializing_if = "Option::is_none")]
107 password: Option<String>,
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 #[must_use]
148 pub fn password(&self) -> Option<&str> {
149 self.password.as_deref()
150 }
151
152 #[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(
179 &self,
180 figment: &figment::Figment,
181 ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
182 let metadata = figment.find_metadata(Self::PATH.unwrap());
183
184 let error_on_field = |mut error: figment::error::Error, field: &'static str| {
185 error.metadata = metadata.cloned();
186 error.profile = Some(figment::Profile::Default);
187 error.path = vec![Self::PATH.unwrap().to_owned(), field.to_owned()];
188 error
189 };
190
191 let missing_field = |field: &'static str| {
192 error_on_field(figment::error::Error::missing_field(field), field)
193 };
194
195 let unexpected_field = |field: &'static str, expected_fields: &'static [&'static str]| {
196 error_on_field(
197 figment::error::Error::unknown_field(field, expected_fields),
198 field,
199 )
200 };
201
202 match self.transport {
203 EmailTransportKind::Blackhole => {}
204
205 EmailTransportKind::Smtp => {
206 if let Err(e) = Mailbox::from_str(&self.from) {
207 return Err(error_on_field(figment::error::Error::custom(e), "from").into());
208 }
209
210 if let Err(e) = Mailbox::from_str(&self.reply_to) {
211 return Err(error_on_field(figment::error::Error::custom(e), "reply_to").into());
212 }
213
214 match (self.username.is_some(), self.password.is_some()) {
215 (true, true) | (false, false) => {}
216 (true, false) => {
217 return Err(missing_field("password").into());
218 }
219 (false, true) => {
220 return Err(missing_field("username").into());
221 }
222 }
223
224 if self.mode.is_none() {
225 return Err(missing_field("mode").into());
226 }
227
228 if self.hostname.is_none() {
229 return Err(missing_field("hostname").into());
230 }
231
232 if self.command.is_some() {
233 return Err(unexpected_field(
234 "command",
235 &[
236 "from",
237 "reply_to",
238 "transport",
239 "mode",
240 "hostname",
241 "port",
242 "username",
243 "password",
244 ],
245 )
246 .into());
247 }
248 }
249
250 EmailTransportKind::Sendmail => {
251 let expected_fields = &["from", "reply_to", "transport", "command"];
252
253 if let Err(e) = Mailbox::from_str(&self.from) {
254 return Err(error_on_field(figment::error::Error::custom(e), "from").into());
255 }
256
257 if let Err(e) = Mailbox::from_str(&self.reply_to) {
258 return Err(error_on_field(figment::error::Error::custom(e), "reply_to").into());
259 }
260
261 if self.command.is_none() {
262 return Err(missing_field("command").into());
263 }
264
265 if self.mode.is_some() {
266 return Err(unexpected_field("mode", expected_fields).into());
267 }
268
269 if self.hostname.is_some() {
270 return Err(unexpected_field("hostname", expected_fields).into());
271 }
272
273 if self.port.is_some() {
274 return Err(unexpected_field("port", expected_fields).into());
275 }
276
277 if self.username.is_some() {
278 return Err(unexpected_field("username", expected_fields).into());
279 }
280
281 if self.password.is_some() {
282 return Err(unexpected_field("password", expected_fields).into());
283 }
284 }
285 }
286
287 Ok(())
288 }
289}