use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::envelope::KeyEnvelope;
use crate::kv::KvStore;
use crate::project::ProjectRef;
const MAX_PROFILE_NAME_LEN: usize = 128;
const MAX_FIELD_LEN: usize = 1024;
pub const DEFAULT_PROFILE: &str = "default";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SmtpSecurity {
StartTls,
Tls,
Plaintext,
}
impl SmtpSecurity {
#[must_use]
pub fn default_port(self) -> u16 {
match self {
Self::StartTls => 587,
Self::Tls => 465,
Self::Plaintext => 25,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::StartTls => "starttls",
Self::Tls => "tls",
Self::Plaintext => "plaintext",
}
}
}
impl std::str::FromStr for SmtpSecurity {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"starttls" => Ok(Self::StartTls),
"tls" | "smtps" | "implicit" => Ok(Self::Tls),
"plaintext" | "none" | "plain" => Ok(Self::Plaintext),
other => Err(format!(
"unknown SMTP security {other:?} (expected starttls|tls|plaintext)"
)),
}
}
}
impl std::fmt::Display for SmtpSecurity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmailProfile {
pub host: String,
pub port: u16,
pub security: SmtpSecurity,
pub username: Option<String>,
pub password: Option<String>,
pub from: String,
pub durable: bool,
}
impl EmailProfile {
#[must_use]
pub fn sender_allowed(&self, addr: &str) -> bool {
addr.eq_ignore_ascii_case(&self.from)
}
}
#[derive(Debug, Clone, Default)]
pub struct EmailProfilePatch {
pub host: Option<String>,
pub port: Option<u16>,
pub security: Option<SmtpSecurity>,
pub username: Option<String>,
pub password: Option<String>,
pub from: Option<String>,
pub durable: Option<bool>,
pub clear_auth: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmailProfileInfo {
pub name: String,
pub host: String,
pub port: u16,
pub security: SmtpSecurity,
pub username: Option<String>,
pub from: String,
pub durable: bool,
pub has_password: bool,
pub created_at: u64,
pub updated_at: u64,
pub revision: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClearConfig {
host: String,
port: u16,
security: SmtpSecurity,
username: Option<String>,
from: String,
durable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct EmailRecord {
version: u32,
created_at: u64,
updated_at: u64,
revision: u32,
config: ClearConfig,
sealed_password: Option<Vec<u8>>,
}
impl EmailRecord {
fn info(&self, name: &str) -> EmailProfileInfo {
EmailProfileInfo {
name: name.to_string(),
host: self.config.host.clone(),
port: self.config.port,
security: self.config.security,
username: self.config.username.clone(),
from: self.config.from.clone(),
durable: self.config.durable,
has_password: self.sealed_password.is_some(),
created_at: self.created_at,
updated_at: self.updated_at,
revision: self.revision,
}
}
}
#[derive(Debug)]
pub enum EmailProfileError {
InvalidName(String),
InvalidConfig(String),
Backend(String),
}
impl std::fmt::Display for EmailProfileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidName(m) | Self::InvalidConfig(m) | Self::Backend(m) => write!(f, "{m}"),
}
}
}
impl std::error::Error for EmailProfileError {}
impl EmailProfileError {
#[must_use]
pub fn is_client_error(&self) -> bool {
matches!(self, Self::InvalidName(_) | Self::InvalidConfig(_))
}
}
#[derive(Clone)]
pub struct EmailProfileStore {
kv: Arc<dyn KvStore>,
envelope: Arc<dyn KeyEnvelope>,
}
impl EmailProfileStore {
#[must_use]
pub fn new(kv: Arc<dyn KvStore>, envelope: Arc<dyn KeyEnvelope>) -> Self {
Self { kv, envelope }
}
pub async fn set(
&self,
project: ProjectRef<'_>,
name: &str,
profile: &EmailProfile,
) -> Result<EmailProfileInfo, EmailProfileError> {
validate_name(name)?;
validate_config(profile)?;
let key = crate::deploy::keys::email_profile(project, name);
let now = crate::time::now_unix();
let prev = self.load_record(&key).await?;
let created_at = prev.as_ref().map_or(now, |r| r.created_at);
let revision = prev.as_ref().map_or(0, |r| r.revision) + 1;
let sealed_password = match &profile.password {
Some(pw) => Some(
self.envelope
.wrap(pw.as_bytes())
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?,
),
None => None,
};
let record = EmailRecord {
version: 1,
created_at,
updated_at: now,
revision,
config: ClearConfig {
host: profile.host.clone(),
port: profile.port,
security: profile.security,
username: profile.username.clone(),
from: profile.from.clone(),
durable: profile.durable,
},
sealed_password,
};
let bytes =
serde_json::to_vec(&record).map_err(|e| EmailProfileError::Backend(e.to_string()))?;
self.kv
.put(&key, bytes)
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?;
Ok(record.info(name))
}
pub async fn patch(
&self,
project: ProjectRef<'_>,
name: &str,
patch: &EmailProfilePatch,
) -> Result<EmailProfileInfo, EmailProfileError> {
validate_name(name)?;
if let Some(pw) = &patch.password {
validate_password_len(pw)?;
}
let key = crate::deploy::keys::email_profile(project, name);
let now = crate::time::now_unix();
let prev = self.load_record(&key).await?;
let created_at = prev.as_ref().map_or(now, |r| r.created_at);
let revision = prev.as_ref().map_or(0, |r| r.revision) + 1;
let mut config = prev.as_ref().map_or_else(
|| ClearConfig {
host: String::new(),
port: 0,
security: SmtpSecurity::StartTls,
username: None,
from: String::new(),
durable: false,
},
|r| r.config.clone(),
);
let mut sealed_password = prev.as_ref().and_then(|r| r.sealed_password.clone());
if let Some(s) = patch.security {
config.security = s;
}
if let Some(h) = &patch.host {
config.host = h.clone();
}
if let Some(f) = &patch.from {
config.from = f.clone();
}
if let Some(d) = patch.durable {
config.durable = d;
}
if let Some(p) = patch.port {
config.port = p;
} else if prev.is_none() {
config.port = config.security.default_port();
}
if patch.clear_auth {
config.username = None;
sealed_password = None;
} else {
if let Some(u) = &patch.username {
config.username = Some(u.clone());
}
if let Some(pw) = &patch.password {
sealed_password = Some(
self.envelope
.wrap(pw.as_bytes())
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?,
);
}
}
validate_fields(
&config.host,
config.port,
&config.from,
config.username.as_deref(),
)?;
let record = EmailRecord {
version: 1,
created_at,
updated_at: now,
revision,
config,
sealed_password,
};
let bytes =
serde_json::to_vec(&record).map_err(|e| EmailProfileError::Backend(e.to_string()))?;
self.kv
.put(&key, bytes)
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?;
Ok(record.info(name))
}
pub async fn get(
&self,
project: ProjectRef<'_>,
name: &str,
) -> Result<Option<EmailProfile>, EmailProfileError> {
validate_name(name)?;
let key = crate::deploy::keys::email_profile(project, name);
match self.load_record(&key).await? {
Some(r) => Ok(Some(self.unseal(r).await?)),
None => Ok(None),
}
}
pub async fn get_info(
&self,
project: ProjectRef<'_>,
name: &str,
) -> Result<Option<EmailProfileInfo>, EmailProfileError> {
validate_name(name)?;
let key = crate::deploy::keys::email_profile(project, name);
Ok(self.load_record(&key).await?.map(|r| r.info(name)))
}
pub async fn list(
&self,
project: ProjectRef<'_>,
) -> Result<Vec<EmailProfileInfo>, EmailProfileError> {
let prefix = crate::deploy::keys::email_profile_prefix(project);
let mut out = Vec::new();
for key in self
.kv
.list_prefix(&prefix)
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?
{
let name = key.strip_prefix(&prefix).unwrap_or(&key).to_string();
if let Some(bytes) = self
.kv
.get(&key)
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?
{
if let Ok(record) = serde_json::from_slice::<EmailRecord>(&bytes) {
out.push(record.info(&name));
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub async fn resolve_all(
&self,
project: ProjectRef<'_>,
) -> Result<BTreeMap<String, EmailProfile>, EmailProfileError> {
let prefix = crate::deploy::keys::email_profile_prefix(project);
let mut out = BTreeMap::new();
for key in self
.kv
.list_prefix(&prefix)
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?
{
let name = key.strip_prefix(&prefix).unwrap_or(&key).to_string();
if let Some(bytes) = self
.kv
.get(&key)
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?
{
if let Ok(record) = serde_json::from_slice::<EmailRecord>(&bytes) {
if let Ok(profile) = self.unseal(record).await {
out.insert(name, profile);
}
}
}
}
Ok(out)
}
pub async fn delete(
&self,
project: ProjectRef<'_>,
name: &str,
) -> Result<bool, EmailProfileError> {
validate_name(name)?;
let key = crate::deploy::keys::email_profile(project, name);
let existed = self
.kv
.get(&key)
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?
.is_some();
if existed {
self.kv
.delete(&key)
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?;
}
Ok(existed)
}
async fn unseal(&self, record: EmailRecord) -> Result<EmailProfile, EmailProfileError> {
let password = match &record.sealed_password {
Some(sealed) => {
let bytes = self
.envelope
.unwrap(sealed)
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?;
Some(
String::from_utf8(bytes)
.map_err(|e| EmailProfileError::Backend(e.to_string()))?,
)
}
None => None,
};
Ok(EmailProfile {
host: record.config.host,
port: record.config.port,
security: record.config.security,
username: record.config.username,
password,
from: record.config.from,
durable: record.config.durable,
})
}
async fn load_record(&self, key: &str) -> Result<Option<EmailRecord>, EmailProfileError> {
match self
.kv
.get(key)
.await
.map_err(|e| EmailProfileError::Backend(e.to_string()))?
{
Some(bytes) => serde_json::from_slice::<EmailRecord>(&bytes)
.map(Some)
.map_err(|e| {
EmailProfileError::Backend(format!("corrupt email profile at {key}: {e}"))
}),
None => Ok(None),
}
}
}
fn validate_name(name: &str) -> Result<(), EmailProfileError> {
if name.is_empty() || name.len() > MAX_PROFILE_NAME_LEN {
return Err(EmailProfileError::InvalidName(format!(
"email profile name must be 1..={MAX_PROFILE_NAME_LEN} characters"
)));
}
if name == "." || name == ".." {
return Err(EmailProfileError::InvalidName(
"email profile name must not be '.' or '..'".to_string(),
));
}
if !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return Err(EmailProfileError::InvalidName(
"email profile name may contain only [A-Za-z0-9._-]".to_string(),
));
}
Ok(())
}
fn validate_config(profile: &EmailProfile) -> Result<(), EmailProfileError> {
validate_fields(
&profile.host,
profile.port,
&profile.from,
profile.username.as_deref(),
)?;
if let Some(p) = &profile.password {
validate_password_len(p)?;
}
Ok(())
}
fn validate_fields(
host: &str,
port: u16,
from: &str,
username: Option<&str>,
) -> Result<(), EmailProfileError> {
let bounded = |field: &str, value: &str| -> Result<(), EmailProfileError> {
if value.is_empty() {
return Err(EmailProfileError::InvalidConfig(format!(
"{field} is required"
)));
}
if value.len() > MAX_FIELD_LEN {
return Err(EmailProfileError::InvalidConfig(format!(
"{field} exceeds {MAX_FIELD_LEN} bytes"
)));
}
Ok(())
};
bounded("host", host)?;
bounded("from", from)?;
if port == 0 {
return Err(EmailProfileError::InvalidConfig(
"port must be non-zero".into(),
));
}
match from.split_once('@') {
Some((local, domain)) if !local.is_empty() && domain.contains('.') => {}
_ => {
return Err(EmailProfileError::InvalidConfig(
"from must be a valid email address (local@domain)".into(),
))
}
}
if let Some(u) = username {
bounded("username", u)?;
}
Ok(())
}
fn validate_password_len(password: &str) -> Result<(), EmailProfileError> {
if password.len() > MAX_FIELD_LEN {
return Err(EmailProfileError::InvalidConfig(format!(
"password exceeds {MAX_FIELD_LEN} bytes"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::envelope::EnvelopeError;
use crate::kv::MemoryKv;
struct XorEnvelope;
#[async_trait::async_trait]
impl KeyEnvelope for XorEnvelope {
async fn wrap(&self, plaintext: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(plaintext.iter().map(|b| b ^ 0x5a).collect())
}
async fn unwrap(&self, wrapped: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(wrapped.iter().map(|b| b ^ 0x5a).collect())
}
}
fn store() -> EmailProfileStore {
EmailProfileStore::new(Arc::new(MemoryKv::new()), Arc::new(XorEnvelope))
}
fn profile() -> EmailProfile {
EmailProfile {
host: "smtp.example.com".into(),
port: 587,
security: SmtpSecurity::StartTls,
username: Some("apikey".into()),
password: Some("s3cr3t".into()),
from: "no-reply@example.com".into(),
durable: false,
}
}
#[tokio::test]
async fn set_get_round_trips_and_password_is_sealed_at_rest() {
let s = store();
let p = ProjectRef::new("acme");
let info = s.set(p, "default", &profile()).await.unwrap();
assert_eq!(info.revision, 1);
assert!(info.has_password);
let got = s.get(p, "default").await.unwrap().unwrap();
assert_eq!(got, profile());
let kv = Arc::new(MemoryKv::new());
let s2 = EmailProfileStore::new(kv.clone(), Arc::new(XorEnvelope));
s2.set(p, "default", &profile()).await.unwrap();
let raw = kv
.get(&crate::deploy::keys::email_profile(p, "default"))
.await
.unwrap()
.unwrap();
assert!(
!raw.windows(6).any(|w| w == b"s3cr3t"),
"password must never be stored in the clear"
);
}
#[tokio::test]
async fn info_and_list_redact_the_password() {
let s = store();
let p = ProjectRef::new("acme");
s.set(p, "default", &profile()).await.unwrap();
let info = s.get_info(p, "default").await.unwrap().unwrap();
assert_eq!(info.host, "smtp.example.com");
assert_eq!(info.from, "no-reply@example.com");
assert!(info.has_password);
let json = serde_json::to_string(&info).unwrap();
assert!(
!json.contains("s3cr3t"),
"redacted info leaked the password: {json}"
);
let listed = s.list(p).await.unwrap();
assert_eq!(listed.len(), 1);
assert!(!serde_json::to_string(&listed).unwrap().contains("s3cr3t"));
}
#[tokio::test]
async fn reconfigure_preserves_created_at_and_bumps_revision() {
let s = store();
let p = ProjectRef::new("acme");
let m1 = s.set(p, "default", &profile()).await.unwrap();
let mut p2 = profile();
p2.host = "smtp2.example.com".into();
let m2 = s.set(p, "default", &p2).await.unwrap();
assert_eq!(m2.revision, 2);
assert_eq!(m2.created_at, m1.created_at);
assert_eq!(
s.get(p, "default").await.unwrap().unwrap().host,
"smtp2.example.com"
);
}
#[tokio::test]
async fn patch_changes_one_field_and_keeps_the_rest_including_the_password() {
let s = store();
let p = ProjectRef::new("acme");
s.set(p, "default", &profile()).await.unwrap();
let info = s
.patch(
p,
"default",
&EmailProfilePatch {
host: Some("smtp2.example.com".into()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(info.revision, 2);
assert!(info.has_password, "password must survive a host-only edit");
let got = s.get(p, "default").await.unwrap().unwrap();
assert_eq!(got.host, "smtp2.example.com");
assert_eq!(got.port, 587); assert_eq!(got.from, "no-reply@example.com"); assert_eq!(got.username.as_deref(), Some("apikey")); assert_eq!(got.password.as_deref(), Some("s3cr3t")); }
#[tokio::test]
async fn patch_rotates_only_the_password() {
let s = store();
let p = ProjectRef::new("acme");
s.set(p, "default", &profile()).await.unwrap();
s.patch(
p,
"default",
&EmailProfilePatch {
password: Some("rotated".into()),
..Default::default()
},
)
.await
.unwrap();
let got = s.get(p, "default").await.unwrap().unwrap();
assert_eq!(got.password.as_deref(), Some("rotated"));
assert_eq!(got.host, "smtp.example.com"); }
#[tokio::test]
async fn patch_clear_auth_drops_username_and_password() {
let s = store();
let p = ProjectRef::new("acme");
s.set(p, "default", &profile()).await.unwrap();
let info = s
.patch(
p,
"default",
&EmailProfilePatch {
clear_auth: true,
..Default::default()
},
)
.await
.unwrap();
assert!(!info.has_password);
let got = s.get(p, "default").await.unwrap().unwrap();
assert_eq!(got.username, None);
assert_eq!(got.password, None);
}
#[tokio::test]
async fn patch_creates_when_absent_but_requires_host_and_from() {
let s = store();
let p = ProjectRef::new("acme");
assert!(s
.patch(
p,
"new",
&EmailProfilePatch {
host: Some("smtp.example.com".into()),
..Default::default()
},
)
.await
.is_err());
let info = s
.patch(
p,
"new",
&EmailProfilePatch {
host: Some("smtp.example.com".into()),
from: Some("hi@example.com".into()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(info.revision, 1);
let got = s.get(p, "new").await.unwrap().unwrap();
assert_eq!(got.port, 587); assert_eq!(got.password, None); }
#[tokio::test]
async fn profiles_are_isolated_per_project() {
let s = store();
s.set(ProjectRef::new("acme"), "default", &profile())
.await
.unwrap();
assert!(s
.get(ProjectRef::new("globex"), "default")
.await
.unwrap()
.is_none());
assert!(s.list(ProjectRef::new("globex")).await.unwrap().is_empty());
assert!(s
.resolve_all(ProjectRef::new("globex"))
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn resolve_all_returns_full_profiles_by_name() {
let s = store();
let p = ProjectRef::new("acme");
s.set(p, "default", &profile()).await.unwrap();
let mut marketing = profile();
marketing.from = "hello@example.com".into();
s.set(p, "marketing", &marketing).await.unwrap();
let all = s.resolve_all(p).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all["default"].password.as_deref(), Some("s3cr3t"));
assert_eq!(all["marketing"].from, "hello@example.com");
}
#[tokio::test]
async fn an_unauthenticated_relay_has_no_password() {
let s = store();
let p = ProjectRef::new("acme");
let mut relay = profile();
relay.username = None;
relay.password = None;
let info = s.set(p, "relay", &relay).await.unwrap();
assert!(!info.has_password);
assert_eq!(s.get(p, "relay").await.unwrap().unwrap().password, None);
}
#[tokio::test]
async fn delete_reports_existence_then_removes() {
let s = store();
let p = ProjectRef::new("acme");
s.set(p, "default", &profile()).await.unwrap();
assert!(s.delete(p, "default").await.unwrap());
assert!(!s.delete(p, "default").await.unwrap());
assert!(s.get(p, "default").await.unwrap().is_none());
}
#[tokio::test]
async fn invalid_names_are_refused_fail_closed() {
let s = store();
let p = ProjectRef::new("acme");
for bad in ["", "has/slash", "..", ".", "space bad", &"x".repeat(129)] {
assert!(
s.set(p, bad, &profile()).await.is_err(),
"name {bad:?} must be rejected"
);
}
assert!(s.set(p, "ok.name_1-2", &profile()).await.is_ok());
}
#[tokio::test]
async fn invalid_config_is_refused() {
let s = store();
let p = ProjectRef::new("acme");
let bad_from = EmailProfile {
from: "not-an-email".into(),
..profile()
};
let e = s.set(p, "x", &bad_from).await.unwrap_err();
assert!(matches!(e, EmailProfileError::InvalidConfig(_)) && e.is_client_error());
let empty_host = EmailProfile {
host: String::new(),
..profile()
};
assert!(s.set(p, "y", &empty_host).await.is_err());
let zero_port = EmailProfile {
port: 0,
..profile()
};
assert!(s.set(p, "z", &zero_port).await.is_err());
}
#[test]
fn sender_allowed_matches_case_insensitively_only_the_configured_from() {
let pr = profile();
assert!(pr.sender_allowed("no-reply@example.com"));
assert!(pr.sender_allowed("No-Reply@Example.COM"));
assert!(!pr.sender_allowed("someone-else@example.com"));
}
#[test]
fn security_parses_and_has_conventional_ports() {
use std::str::FromStr;
assert_eq!(
SmtpSecurity::from_str("STARTTLS").unwrap(),
SmtpSecurity::StartTls
);
assert_eq!(SmtpSecurity::from_str("tls").unwrap(), SmtpSecurity::Tls);
assert_eq!(
SmtpSecurity::from_str("plaintext").unwrap(),
SmtpSecurity::Plaintext
);
assert!(SmtpSecurity::from_str("bogus").is_err());
assert_eq!(SmtpSecurity::StartTls.default_port(), 587);
assert_eq!(SmtpSecurity::Tls.default_port(), 465);
}
}