Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.

Commit 6d77d0e

Browse files
committed
Flatten the email config
1 parent bf50469 commit 6d77d0e

File tree

5 files changed

+293
-161
lines changed

5 files changed

+293
-161
lines changed

crates/cli/src/util.rs

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::time::Duration;
1616

1717
use anyhow::Context;
1818
use mas_config::{
19-
BrandingConfig, DatabaseConfig, EmailConfig, EmailSmtpMode, EmailTransportConfig,
19+
BrandingConfig, DatabaseConfig, EmailConfig, EmailSmtpMode, EmailTransportKind,
2020
PasswordsConfig, PolicyConfig, TemplatesConfig,
2121
};
2222
use mas_email::{MailTransport, Mailer};
@@ -61,30 +61,39 @@ pub fn mailer_from_config(
6161
) -> Result<Mailer, anyhow::Error> {
6262
let from = config.from.parse()?;
6363
let reply_to = config.reply_to.parse()?;
64-
let transport = match &config.transport {
65-
EmailTransportConfig::Blackhole => MailTransport::blackhole(),
66-
EmailTransportConfig::Smtp {
67-
mode,
68-
hostname,
69-
credentials,
70-
port,
71-
} => {
72-
let credentials = credentials
73-
.clone()
74-
.map(|c| mas_email::SmtpCredentials::new(c.username, c.password));
64+
let transport = match config.transport() {
65+
EmailTransportKind::Blackhole => MailTransport::blackhole(),
66+
EmailTransportKind::Smtp => {
67+
// This should have been set ahead of time
68+
let hostname = config
69+
.hostname()
70+
.context("invalid configuration: missing hostname")?;
71+
72+
let mode = config
73+
.mode()
74+
.context("invalid configuration: missing mode")?;
75+
76+
let credentials = match (config.username(), config.password()) {
77+
(Some(username), Some(password)) => Some(mas_email::SmtpCredentials::new(
78+
username.to_owned(),
79+
password.to_owned(),
80+
)),
81+
(None, None) => None,
82+
_ => {
83+
anyhow::bail!("invalid configuration: missing username or password");
84+
}
85+
};
7586

7687
let mode = match mode {
7788
EmailSmtpMode::Plain => mas_email::SmtpMode::Plain,
7889
EmailSmtpMode::StartTls => mas_email::SmtpMode::StartTls,
7990
EmailSmtpMode::Tls => mas_email::SmtpMode::Tls,
8091
};
8192

82-
MailTransport::smtp(mode, hostname, port.as_ref().copied(), credentials)
93+
MailTransport::smtp(mode, hostname, config.port(), credentials)
8394
.context("failed to build SMTP transport")?
8495
}
85-
EmailTransportConfig::Sendmail { command } => MailTransport::sendmail(command),
86-
#[allow(deprecated)]
87-
EmailTransportConfig::AwsSes => anyhow::bail!("AWS SESv2 backend has been removed"),
96+
EmailTransportKind::Sendmail => MailTransport::sendmail(config.command()),
8897
};
8998

9099
Ok(Mailer::new(templates.clone(), transport, from, reply_to))

crates/config/src/sections/email.rs

Lines changed: 188 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use std::num::NonZeroU16;
1919
use async_trait::async_trait;
2020
use rand::Rng;
2121
use schemars::JsonSchema;
22-
use serde::{Deserialize, Serialize};
22+
use serde::{de::Error, Deserialize, Serialize};
2323

2424
use super::ConfigurationSection;
2525

@@ -47,55 +47,27 @@ pub enum EmailSmtpMode {
4747
}
4848

4949
/// What backend should be used when sending emails
50-
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
51-
#[serde(tag = "transport", rename_all = "snake_case")]
52-
pub enum EmailTransportConfig {
50+
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Default)]
51+
#[serde(rename_all = "snake_case")]
52+
pub enum EmailTransportKind {
5353
/// Don't send emails anywhere
54+
#[default]
5455
Blackhole,
5556

5657
/// Send emails via an SMTP relay
57-
Smtp {
58-
/// Connection mode to the relay
59-
mode: EmailSmtpMode,
60-
61-
/// Hostname to connect to
62-
#[schemars(with = "crate::schema::Hostname")]
63-
hostname: String,
64-
65-
/// Port to connect to. Default is 25 for plain, 465 for TLS and 587 for
66-
/// StartTLS
67-
#[serde(default, skip_serializing_if = "Option::is_none")]
68-
port: Option<NonZeroU16>,
69-
70-
/// Set of credentials to use
71-
#[serde(flatten, default)]
72-
credentials: Option<Credentials>,
73-
},
58+
Smtp,
7459

7560
/// Send emails by calling sendmail
76-
Sendmail {
77-
/// Command to execute
78-
#[serde(default = "default_sendmail_command")]
79-
command: String,
80-
},
81-
82-
/// Send emails via the AWS SESv2 API
83-
#[deprecated(note = "The AWS SESv2 backend has be removed.")]
84-
AwsSes,
85-
}
86-
87-
impl Default for EmailTransportConfig {
88-
fn default() -> Self {
89-
Self::Blackhole
90-
}
61+
Sendmail,
9162
}
9263

9364
fn default_email() -> String {
9465
r#""Authentication Service" <root@localhost>"#.to_owned()
9566
}
9667

97-
fn default_sendmail_command() -> String {
98-
"sendmail".to_owned()
68+
#[allow(clippy::unnecessary_wraps)]
69+
fn default_sendmail_command() -> Option<String> {
70+
Some("sendmail".to_owned())
9971
}
10072

10173
/// Configuration related to sending emails
@@ -112,16 +84,99 @@ pub struct EmailConfig {
11284
pub reply_to: String,
11385

11486
/// What backend should be used when sending emails
115-
#[serde(flatten, default)]
116-
pub transport: EmailTransportConfig,
87+
transport: EmailTransportKind,
88+
89+
/// SMTP transport: Connection mode to the relay
90+
#[serde(skip_serializing_if = "Option::is_none")]
91+
mode: Option<EmailSmtpMode>,
92+
93+
/// SMTP transport: Hostname to connect to
94+
#[serde(skip_serializing_if = "Option::is_none")]
95+
#[schemars(with = "Option<crate::schema::Hostname>")]
96+
hostname: Option<String>,
97+
98+
/// SMTP transport: Port to connect to. Default is 25 for plain, 465 for TLS
99+
/// and 587 for StartTLS
100+
#[serde(skip_serializing_if = "Option::is_none")]
101+
#[schemars(range(min = 1, max = 65535))]
102+
port: Option<NonZeroU16>,
103+
104+
/// SMTP transport: Username for use to authenticate when connecting to the
105+
/// SMTP server
106+
///
107+
/// Must be set if the `password` field is set
108+
#[serde(skip_serializing_if = "Option::is_none")]
109+
username: Option<String>,
110+
111+
/// SMTP transport: Password for use to authenticate when connecting to the
112+
/// SMTP server
113+
///
114+
/// Must be set if the `username` field is set
115+
#[serde(skip_serializing_if = "Option::is_none")]
116+
password: Option<String>,
117+
118+
/// Sendmail transport: Command to use to send emails
119+
#[serde(skip_serializing_if = "Option::is_none")]
120+
#[schemars(default = "default_sendmail_command")]
121+
command: Option<String>,
122+
}
123+
124+
impl EmailConfig {
125+
/// What backend should be used when sending emails
126+
#[must_use]
127+
pub fn transport(&self) -> EmailTransportKind {
128+
self.transport
129+
}
130+
131+
/// Connection mode to the relay
132+
#[must_use]
133+
pub fn mode(&self) -> Option<EmailSmtpMode> {
134+
self.mode
135+
}
136+
137+
/// Hostname to connect to
138+
#[must_use]
139+
pub fn hostname(&self) -> Option<&str> {
140+
self.hostname.as_deref()
141+
}
142+
143+
/// Port to connect to
144+
#[must_use]
145+
pub fn port(&self) -> Option<NonZeroU16> {
146+
self.port
147+
}
148+
149+
/// Username for use to authenticate when connecting to the SMTP server
150+
#[must_use]
151+
pub fn username(&self) -> Option<&str> {
152+
self.username.as_deref()
153+
}
154+
155+
/// Password for use to authenticate when connecting to the SMTP server
156+
#[must_use]
157+
pub fn password(&self) -> Option<&str> {
158+
self.password.as_deref()
159+
}
160+
161+
/// Command to use to send emails
162+
#[must_use]
163+
pub fn command(&self) -> Option<&str> {
164+
self.command.as_deref()
165+
}
117166
}
118167

119168
impl Default for EmailConfig {
120169
fn default() -> Self {
121170
Self {
122171
from: default_email(),
123172
reply_to: default_email(),
124-
transport: EmailTransportConfig::Blackhole,
173+
transport: EmailTransportKind::Blackhole,
174+
mode: None,
175+
hostname: None,
176+
port: None,
177+
username: None,
178+
password: None,
179+
command: None,
125180
}
126181
}
127182
}
@@ -137,6 +192,98 @@ impl ConfigurationSection for EmailConfig {
137192
Ok(Self::default())
138193
}
139194

195+
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::error::Error> {
196+
let metadata = figment.find_metadata(Self::PATH.unwrap());
197+
198+
let error_on_field = |mut error: figment::error::Error, field: &'static str| {
199+
error.metadata = metadata.cloned();
200+
error.profile = Some(figment::Profile::Default);
201+
error.path = vec![Self::PATH.unwrap().to_owned(), field.to_owned()];
202+
error
203+
};
204+
205+
let missing_field = |field: &'static str| {
206+
error_on_field(figment::error::Error::missing_field(field), field)
207+
};
208+
209+
let unexpected_field = |field: &'static str, expected_fields: &'static [&'static str]| {
210+
error_on_field(
211+
figment::error::Error::unknown_field(field, expected_fields),
212+
field,
213+
)
214+
};
215+
216+
match self.transport {
217+
EmailTransportKind::Blackhole => {}
218+
219+
EmailTransportKind::Smtp => {
220+
match (self.username.is_some(), self.password.is_some()) {
221+
(true, true) | (false, false) => {}
222+
(true, false) => {
223+
return Err(missing_field("password"));
224+
}
225+
(false, true) => {
226+
return Err(missing_field("username"));
227+
}
228+
}
229+
230+
if self.mode.is_none() {
231+
return Err(missing_field("mode"));
232+
}
233+
234+
if self.hostname.is_none() {
235+
return Err(missing_field("hostname"));
236+
}
237+
238+
if self.command.is_some() {
239+
return Err(unexpected_field(
240+
"command",
241+
&[
242+
"from",
243+
"reply_to",
244+
"transport",
245+
"mode",
246+
"hostname",
247+
"port",
248+
"username",
249+
"password",
250+
],
251+
));
252+
}
253+
}
254+
255+
EmailTransportKind::Sendmail => {
256+
let expected_fields = &["from", "reply_to", "transport", "command"];
257+
258+
if self.command.is_none() {
259+
return Err(missing_field("command"));
260+
}
261+
262+
if self.mode.is_some() {
263+
return Err(unexpected_field("mode", expected_fields));
264+
}
265+
266+
if self.hostname.is_some() {
267+
return Err(unexpected_field("hostname", expected_fields));
268+
}
269+
270+
if self.port.is_some() {
271+
return Err(unexpected_field("port", expected_fields));
272+
}
273+
274+
if self.username.is_some() {
275+
return Err(unexpected_field("username", expected_fields));
276+
}
277+
278+
if self.password.is_some() {
279+
return Err(unexpected_field("password", expected_fields));
280+
}
281+
}
282+
}
283+
284+
Ok(())
285+
}
286+
140287
fn test() -> Self {
141288
Self::default()
142289
}

crates/config/src/sections/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub use self::{
3535
branding::BrandingConfig,
3636
clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig},
3737
database::DatabaseConfig,
38-
email::{EmailConfig, EmailSmtpMode, EmailTransportConfig},
38+
email::{EmailConfig, EmailSmtpMode, EmailTransportKind},
3939
experimental::ExperimentalConfig,
4040
http::{
4141
BindConfig as HttpBindConfig, HttpConfig, ListenerConfig as HttpListenerConfig,

crates/email/src/transport.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,13 @@ impl Transport {
9292

9393
/// Construct a Sendmail transport
9494
#[must_use]
95-
pub fn sendmail(command: impl Into<OsString>) -> Self {
96-
Self::new(TransportInner::Sendmail(
97-
AsyncSendmailTransport::new_with_command(command),
98-
))
95+
pub fn sendmail(command: Option<impl Into<OsString>>) -> Self {
96+
let transport = if let Some(command) = command {
97+
AsyncSendmailTransport::new_with_command(command)
98+
} else {
99+
AsyncSendmailTransport::new()
100+
};
101+
Self::new(TransportInner::Sendmail(transport))
99102
}
100103
}
101104

0 commit comments

Comments
 (0)