diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 8ebe6d25..a8c47603 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -16,7 +16,7 @@ use std::time::Duration; use anyhow::Context; use mas_config::{ - BrandingConfig, DatabaseConfig, EmailConfig, EmailSmtpMode, EmailTransportConfig, + BrandingConfig, DatabaseConfig, EmailConfig, EmailSmtpMode, EmailTransportKind, PasswordsConfig, PolicyConfig, TemplatesConfig, }; use mas_email::{MailTransport, Mailer}; @@ -61,17 +61,28 @@ pub fn mailer_from_config( ) -> Result { let from = config.from.parse()?; let reply_to = config.reply_to.parse()?; - let transport = match &config.transport { - EmailTransportConfig::Blackhole => MailTransport::blackhole(), - EmailTransportConfig::Smtp { - mode, - hostname, - credentials, - port, - } => { - let credentials = credentials - .clone() - .map(|c| mas_email::SmtpCredentials::new(c.username, c.password)); + let transport = match config.transport() { + EmailTransportKind::Blackhole => MailTransport::blackhole(), + EmailTransportKind::Smtp => { + // This should have been set ahead of time + let hostname = config + .hostname() + .context("invalid configuration: missing hostname")?; + + let mode = config + .mode() + .context("invalid configuration: missing mode")?; + + let credentials = match (config.username(), config.password()) { + (Some(username), Some(password)) => Some(mas_email::SmtpCredentials::new( + username.to_owned(), + password.to_owned(), + )), + (None, None) => None, + _ => { + anyhow::bail!("invalid configuration: missing username or password"); + } + }; let mode = match mode { EmailSmtpMode::Plain => mas_email::SmtpMode::Plain, @@ -79,12 +90,10 @@ pub fn mailer_from_config( EmailSmtpMode::Tls => mas_email::SmtpMode::Tls, }; - MailTransport::smtp(mode, hostname, port.as_ref().copied(), credentials) + MailTransport::smtp(mode, hostname, config.port(), credentials) .context("failed to build SMTP transport")? } - EmailTransportConfig::Sendmail { command } => MailTransport::sendmail(command), - #[allow(deprecated)] - EmailTransportConfig::AwsSes => anyhow::bail!("AWS SESv2 backend has been removed"), + EmailTransportKind::Sendmail => MailTransport::sendmail(config.command()), }; Ok(Mailer::new(templates.clone(), transport, from, reply_to)) diff --git a/crates/config/src/sections/email.rs b/crates/config/src/sections/email.rs index af291c94..f67fdaa2 100644 --- a/crates/config/src/sections/email.rs +++ b/crates/config/src/sections/email.rs @@ -19,7 +19,7 @@ use std::num::NonZeroU16; use async_trait::async_trait; use rand::Rng; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{de::Error, Deserialize, Serialize}; use super::ConfigurationSection; @@ -47,55 +47,27 @@ pub enum EmailSmtpMode { } /// What backend should be used when sending emails -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "transport", rename_all = "snake_case")] -pub enum EmailTransportConfig { +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "snake_case")] +pub enum EmailTransportKind { /// Don't send emails anywhere + #[default] Blackhole, /// Send emails via an SMTP relay - Smtp { - /// Connection mode to the relay - mode: EmailSmtpMode, - - /// Hostname to connect to - #[schemars(with = "crate::schema::Hostname")] - hostname: String, - - /// Port to connect to. Default is 25 for plain, 465 for TLS and 587 for - /// StartTLS - #[serde(default, skip_serializing_if = "Option::is_none")] - port: Option, - - /// Set of credentials to use - #[serde(flatten, default)] - credentials: Option, - }, + Smtp, /// Send emails by calling sendmail - Sendmail { - /// Command to execute - #[serde(default = "default_sendmail_command")] - command: String, - }, - - /// Send emails via the AWS SESv2 API - #[deprecated(note = "The AWS SESv2 backend has be removed.")] - AwsSes, -} - -impl Default for EmailTransportConfig { - fn default() -> Self { - Self::Blackhole - } + Sendmail, } fn default_email() -> String { r#""Authentication Service" "#.to_owned() } -fn default_sendmail_command() -> String { - "sendmail".to_owned() +#[allow(clippy::unnecessary_wraps)] +fn default_sendmail_command() -> Option { + Some("sendmail".to_owned()) } /// Configuration related to sending emails @@ -112,8 +84,85 @@ pub struct EmailConfig { pub reply_to: String, /// What backend should be used when sending emails - #[serde(flatten, default)] - pub transport: EmailTransportConfig, + transport: EmailTransportKind, + + /// SMTP transport: Connection mode to the relay + #[serde(skip_serializing_if = "Option::is_none")] + mode: Option, + + /// SMTP transport: Hostname to connect to + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + hostname: Option, + + /// SMTP transport: Port to connect to. Default is 25 for plain, 465 for TLS + /// and 587 for StartTLS + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 65535))] + port: Option, + + /// SMTP transport: Username for use to authenticate when connecting to the + /// SMTP server + /// + /// Must be set if the `password` field is set + #[serde(skip_serializing_if = "Option::is_none")] + username: Option, + + /// SMTP transport: Password for use to authenticate when connecting to the + /// SMTP server + /// + /// Must be set if the `username` field is set + #[serde(skip_serializing_if = "Option::is_none")] + password: Option, + + /// Sendmail transport: Command to use to send emails + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(default = "default_sendmail_command")] + command: Option, +} + +impl EmailConfig { + /// What backend should be used when sending emails + #[must_use] + pub fn transport(&self) -> EmailTransportKind { + self.transport + } + + /// Connection mode to the relay + #[must_use] + pub fn mode(&self) -> Option { + self.mode + } + + /// Hostname to connect to + #[must_use] + pub fn hostname(&self) -> Option<&str> { + self.hostname.as_deref() + } + + /// Port to connect to + #[must_use] + pub fn port(&self) -> Option { + self.port + } + + /// Username for use to authenticate when connecting to the SMTP server + #[must_use] + pub fn username(&self) -> Option<&str> { + self.username.as_deref() + } + + /// Password for use to authenticate when connecting to the SMTP server + #[must_use] + pub fn password(&self) -> Option<&str> { + self.password.as_deref() + } + + /// Command to use to send emails + #[must_use] + pub fn command(&self) -> Option<&str> { + self.command.as_deref() + } } impl Default for EmailConfig { @@ -121,7 +170,13 @@ impl Default for EmailConfig { Self { from: default_email(), reply_to: default_email(), - transport: EmailTransportConfig::Blackhole, + transport: EmailTransportKind::Blackhole, + mode: None, + hostname: None, + port: None, + username: None, + password: None, + command: None, } } } @@ -137,6 +192,98 @@ impl ConfigurationSection for EmailConfig { Ok(Self::default()) } + fn validate(&self, figment: &figment::Figment) -> Result<(), figment::error::Error> { + let metadata = figment.find_metadata(Self::PATH.unwrap()); + + let error_on_field = |mut error: figment::error::Error, field: &'static str| { + error.metadata = metadata.cloned(); + error.profile = Some(figment::Profile::Default); + error.path = vec![Self::PATH.unwrap().to_owned(), field.to_owned()]; + error + }; + + let missing_field = |field: &'static str| { + error_on_field(figment::error::Error::missing_field(field), field) + }; + + let unexpected_field = |field: &'static str, expected_fields: &'static [&'static str]| { + error_on_field( + figment::error::Error::unknown_field(field, expected_fields), + field, + ) + }; + + match self.transport { + EmailTransportKind::Blackhole => {} + + EmailTransportKind::Smtp => { + match (self.username.is_some(), self.password.is_some()) { + (true, true) | (false, false) => {} + (true, false) => { + return Err(missing_field("password")); + } + (false, true) => { + return Err(missing_field("username")); + } + } + + if self.mode.is_none() { + return Err(missing_field("mode")); + } + + if self.hostname.is_none() { + return Err(missing_field("hostname")); + } + + if self.command.is_some() { + return Err(unexpected_field( + "command", + &[ + "from", + "reply_to", + "transport", + "mode", + "hostname", + "port", + "username", + "password", + ], + )); + } + } + + EmailTransportKind::Sendmail => { + let expected_fields = &["from", "reply_to", "transport", "command"]; + + if self.command.is_none() { + return Err(missing_field("command")); + } + + if self.mode.is_some() { + return Err(unexpected_field("mode", expected_fields)); + } + + if self.hostname.is_some() { + return Err(unexpected_field("hostname", expected_fields)); + } + + if self.port.is_some() { + return Err(unexpected_field("port", expected_fields)); + } + + if self.username.is_some() { + return Err(unexpected_field("username", expected_fields)); + } + + if self.password.is_some() { + return Err(unexpected_field("password", expected_fields)); + } + } + } + + Ok(()) + } + fn test() -> Self { Self::default() } diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index 6e439bba..1cd05edc 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -35,7 +35,7 @@ pub use self::{ branding::BrandingConfig, clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig}, database::DatabaseConfig, - email::{EmailConfig, EmailSmtpMode, EmailTransportConfig}, + email::{EmailConfig, EmailSmtpMode, EmailTransportKind}, experimental::ExperimentalConfig, http::{ BindConfig as HttpBindConfig, HttpConfig, ListenerConfig as HttpListenerConfig, diff --git a/crates/email/src/transport.rs b/crates/email/src/transport.rs index 435ef5d1..490e1029 100644 --- a/crates/email/src/transport.rs +++ b/crates/email/src/transport.rs @@ -92,10 +92,13 @@ impl Transport { /// Construct a Sendmail transport #[must_use] - pub fn sendmail(command: impl Into) -> Self { - Self::new(TransportInner::Sendmail( - AsyncSendmailTransport::new_with_command(command), - )) + pub fn sendmail(command: Option>) -> Self { + let transport = if let Some(command) = command { + AsyncSendmailTransport::new_with_command(command) + } else { + AsyncSendmailTransport::new() + }; + Self::new(TransportInner::Sendmail(transport)) } } diff --git a/docs/config.schema.json b/docs/config.schema.json index 7c97cc88..a3e62c22 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1348,105 +1348,8 @@ "EmailConfig": { "description": "Configuration related to sending emails", "type": "object", - "oneOf": [ - { - "description": "Don't send emails anywhere", - "type": "object", - "required": [ - "transport" - ], - "properties": { - "transport": { - "type": "string", - "enum": [ - "blackhole" - ] - } - } - }, - { - "description": "Send emails via an SMTP relay", - "type": "object", - "required": [ - "hostname", - "mode", - "transport" - ], - "properties": { - "transport": { - "type": "string", - "enum": [ - "smtp" - ] - }, - "mode": { - "description": "Connection mode to the relay", - "allOf": [ - { - "$ref": "#/definitions/EmailSmtpMode" - } - ] - }, - "hostname": { - "description": "Hostname to connect to", - "allOf": [ - { - "$ref": "#/definitions/Hostname" - } - ] - }, - "port": { - "description": "Port to connect to. Default is 25 for plain, 465 for TLS and 587 for StartTLS", - "type": "integer", - "format": "uint16", - "minimum": 1.0 - }, - "username": { - "description": "Username for use to authenticate when connecting to the SMTP server", - "type": "string" - }, - "password": { - "description": "Password for use to authenticate when connecting to the SMTP server", - "type": "string" - } - } - }, - { - "description": "Send emails by calling sendmail", - "type": "object", - "required": [ - "transport" - ], - "properties": { - "transport": { - "type": "string", - "enum": [ - "sendmail" - ] - }, - "command": { - "description": "Command to execute", - "default": "sendmail", - "type": "string" - } - } - }, - { - "description": "Send emails via the AWS SESv2 API", - "deprecated": true, - "type": "object", - "required": [ - "transport" - ], - "properties": { - "transport": { - "type": "string", - "enum": [ - "aws_ses" - ] - } - } - } + "required": [ + "transport" ], "properties": { "from": { @@ -1460,9 +1363,79 @@ "default": "\"Authentication Service\" ", "type": "string", "format": "email" + }, + "transport": { + "description": "What backend should be used when sending emails", + "allOf": [ + { + "$ref": "#/definitions/EmailTransportKind" + } + ] + }, + "mode": { + "description": "SMTP transport: Connection mode to the relay", + "allOf": [ + { + "$ref": "#/definitions/EmailSmtpMode" + } + ] + }, + "hostname": { + "description": "SMTP transport: Hostname to connect to", + "allOf": [ + { + "$ref": "#/definitions/Hostname" + } + ] + }, + "port": { + "description": "SMTP transport: Port to connect to. Default is 25 for plain, 465 for TLS and 587 for StartTLS", + "type": "integer", + "format": "uint16", + "maximum": 65535.0, + "minimum": 1.0 + }, + "username": { + "description": "SMTP transport: Username for use to authenticate when connecting to the SMTP server\n\nMust be set if the `password` field is set", + "type": "string" + }, + "password": { + "description": "SMTP transport: Password for use to authenticate when connecting to the SMTP server\n\nMust be set if the `username` field is set", + "type": "string" + }, + "command": { + "description": "Sendmail transport: Command to use to send emails", + "default": "sendmail", + "type": "string" } } }, + "EmailTransportKind": { + "description": "What backend should be used when sending emails", + "oneOf": [ + { + "description": "Don't send emails anywhere", + "type": "string", + "enum": [ + "blackhole" + ] + }, + { + "description": "Send emails via an SMTP relay", + "type": "string", + "enum": [ + "smtp" + ] + }, + { + "description": "Send emails by calling sendmail", + "type": "string", + "enum": [ + "sendmail" + ] + } + ] + }, "EmailSmtpMode": { "description": "Encryption mode to use", "oneOf": [