use std::fmt;
use std::str::FromStr;
use super::network::parse_network_url;
use super::validation::{optional_text, redacted};
use super::{ConfigError, Result, variables};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EmailTransport {
Auto,
#[default]
Log,
MailerSend,
Smtp,
}
impl FromStr for EmailTransport {
type Err = ();
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"log" => Ok(Self::Log),
"mailersend" => Ok(Self::MailerSend),
"smtp" => Ok(Self::Smtp),
_ => Err(()),
}
}
}
impl EmailTransport {
pub const fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Log => "log",
Self::MailerSend => "mailersend",
Self::Smtp => "smtp",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EmailSmtpSecurity {
#[default]
None,
StartTls,
Tls,
}
impl FromStr for EmailSmtpSecurity {
type Err = ();
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"none" => Ok(Self::None),
"starttls" => Ok(Self::StartTls),
"tls" => Ok(Self::Tls),
_ => Err(()),
}
}
}
impl EmailSmtpSecurity {
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::StartTls => "starttls",
Self::Tls => "tls",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EmailConfig {
pub transport: EmailTransport,
pub from_email: String,
pub from_name: String,
pub smtp: EmailSmtpConfig,
pub mailersend: EmailMailerSendConfig,
}
#[derive(Clone, Default, PartialEq, Eq)]
pub struct EmailSmtpConfig {
pub host: String,
pub port: u16,
pub security: EmailSmtpSecurity,
pub username: Option<String>,
pub password: Option<String>,
}
impl fmt::Debug for EmailSmtpConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("EmailSmtpConfig")
.field("host", &self.host)
.field("port", &self.port)
.field("security", &self.security)
.field("username", &self.username.as_deref().map(redacted))
.field("password", &self.password.as_deref().map(redacted))
.finish()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EmailMailerSendConfig {
pub api_url: String,
}
impl EmailConfig {
pub fn mailersend(from_email: impl Into<String>, from_name: impl Into<String>) -> Self {
Self {
transport: EmailTransport::MailerSend,
from_email: from_email.into(),
from_name: from_name.into(),
smtp: EmailSmtpConfig::default(),
mailersend: EmailMailerSendConfig {
api_url: "https://api.mailersend.com/v1/email".to_owned(),
},
}
}
pub fn smtp(
from_email: impl Into<String>,
from_name: impl Into<String>,
host: impl Into<String>,
port: u16,
security: EmailSmtpSecurity,
) -> Self {
Self {
transport: EmailTransport::Smtp,
from_email: from_email.into(),
from_name: from_name.into(),
smtp: EmailSmtpConfig {
host: host.into(),
port,
security,
username: None,
password: None,
},
mailersend: EmailMailerSendConfig {
api_url: "https://api.mailersend.com/v1/email".to_owned(),
},
}
}
pub(crate) fn apply_environment(
&mut self,
value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
) -> Result<()> {
if let Some(transport) = variables::parsed(
"EMAIL_TRANSPORT",
value("EMAIL_TRANSPORT")?,
"must be one of `auto`, `log`, `mailersend`, or `smtp`",
)? {
self.transport = transport;
}
if let Some(from_email) = value("EMAIL_FROM_EMAIL")? {
self.from_email = from_email;
}
if let Some(from_name) = value("EMAIL_FROM_NAME")? {
self.from_name = from_name;
}
if let Some(host) = value("SMTP_HOST")? {
self.smtp.host = host;
}
if let Some(port) = variables::parsed(
"SMTP_PORT",
value("SMTP_PORT")?,
"must be an integer TCP port",
)? {
self.smtp.port = port;
}
if let Some(security) = variables::parsed(
"SMTP_SECURITY",
value("SMTP_SECURITY")?,
"must be one of `none`, `starttls`, or `tls`",
)? {
self.smtp.security = security;
}
if let Some(username) = value("SMTP_USERNAME")? {
self.smtp.username = Some(username);
}
if let Some(password) = value("SMTP_PASSWORD")? {
self.smtp.password = Some(password);
}
if let Some(api_url) = value("MAILERSEND_API_URL")? {
self.mailersend.api_url = api_url;
}
Ok(())
}
pub fn is_configured(&self) -> bool {
self.transport != EmailTransport::Auto
|| !self.from_email.is_empty()
|| !self.from_name.is_empty()
|| self.smtp != EmailSmtpConfig::default()
|| self.mailersend != EmailMailerSendConfig::default()
}
pub fn validate(&self) -> Result<()> {
if !self.from_email.is_empty() {
let address = self.from_email.trim();
if address.is_empty()
|| email_address::EmailAddress::parse_with_options(address, Default::default())
.is_err()
{
return Err(ConfigError::validation(
"email.from_email",
"must be a valid email address",
));
}
}
optional_text(
"email.mailersend.api_url",
(!self.mailersend.api_url.is_empty()).then_some(self.mailersend.api_url.as_str()),
)?;
if !self.mailersend.api_url.is_empty() {
parse_network_url(
"email.mailersend.api_url",
&self.mailersend.api_url,
&["https"],
false,
"must be an HTTPS URL with a host and no embedded credentials",
)?;
}
if !self.is_configured() || self.transport == EmailTransport::Log {
return Ok(());
}
if self.from_email.trim().is_empty() {
return Err(ConfigError::validation(
"email.from_email",
"must not be empty for message delivery",
));
}
if self.from_name.trim().is_empty() {
return Err(ConfigError::validation(
"email.from_name",
"must not be empty for message delivery",
));
}
match self.transport {
EmailTransport::Auto | EmailTransport::Log => Ok(()),
EmailTransport::MailerSend => {
if self.mailersend.api_url.trim().is_empty() {
Err(ConfigError::validation(
"email.mailersend.api_url",
"must not be empty for MailerSend delivery",
))
} else {
Ok(())
}
}
EmailTransport::Smtp => {
if self.smtp.host.trim().is_empty() {
return Err(ConfigError::validation(
"email.smtp.host",
"must not be empty for SMTP delivery",
));
}
if self.smtp.port == 0 {
return Err(ConfigError::validation(
"email.smtp.port",
"must be greater than zero",
));
}
optional_text("email.smtp.username", self.smtp.username.as_deref())?;
optional_text("email.smtp.password", self.smtp.password.as_deref())?;
if self.smtp.username.is_some() != self.smtp.password.is_some() {
return Err(ConfigError::validation(
"email.smtp",
"username and password must be provided together",
));
}
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mailersend_config(from_email: &str, api_url: &str) -> EmailConfig {
EmailConfig {
transport: EmailTransport::MailerSend,
from_email: from_email.to_owned(),
from_name: "Example".to_owned(),
smtp: EmailSmtpConfig::default(),
mailersend: EmailMailerSendConfig {
api_url: api_url.to_owned(),
},
}
}
#[test]
fn validates_sender_address_syntax() {
for invalid in ["not-an-address", "missing-domain@", "a b@example.com"] {
let error = mailersend_config(invalid, "https://api.example.invalid/v1/email")
.validate()
.expect_err("invalid sender must fail");
assert!(error.to_string().contains("email.from_email"));
assert!(!error.to_string().contains(invalid));
}
mailersend_config(
"sender+tag@example.invalid",
"https://api.example.invalid/v1/email",
)
.validate()
.expect("valid sender should pass");
}
#[test]
fn mailersend_requires_https_host_without_credentials() {
for invalid in [
"http://api.example.invalid/v1/email",
"https:///v1/email",
"https://user:secret@api.example.invalid/v1/email",
] {
let error = mailersend_config("sender@example.invalid", invalid)
.validate()
.expect_err("invalid endpoint must fail");
assert!(error.to_string().contains("email.mailersend.api_url"));
assert!(!error.to_string().contains(invalid));
}
}
#[test]
fn inactive_configured_mailersend_endpoint_is_still_validated() {
let mut config = EmailConfig::default();
config.mailersend.api_url = "http://api.example.invalid".to_owned();
assert!(config.validate().is_err());
}
}