use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use thiserror::Error;
pub mod resend;
pub use resend::{ResendEmailConfig, ResendEmailSender};
pub const SYSTEM_EMAIL_FROM: &str = "no-replay@everruns.com";
const SYSTEM_EMAIL_FROM_NAME: &str = "Everruns";
pub type EmailResult<T> = std::result::Result<T, EmailError>;
#[derive(Debug, Error)]
pub enum EmailError {
#[error("Email configuration error: {0}")]
Configuration(String),
#[error("Invalid email request: {0}")]
InvalidRequest(String),
#[error("Email provider transport error: {0}")]
Transport(String),
#[error("Email provider error ({provider}, status {status}): {body}")]
Provider {
provider: &'static str,
status: u16,
body: String,
},
}
impl EmailError {
fn config(message: impl Into<String>) -> Self {
Self::Configuration(message.into())
}
fn invalid(message: impl Into<String>) -> Self {
Self::InvalidRequest(message.into())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmailAddress {
pub email: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl EmailAddress {
pub fn new(email: impl Into<String>) -> Self {
Self {
email: email.into(),
name: None,
}
}
pub fn named(email: impl Into<String>, name: impl Into<String>) -> Self {
Self {
email: email.into(),
name: Some(name.into()),
}
}
fn validate(&self, field: &str) -> EmailResult<()> {
let email = self.email.trim();
if email.is_empty() {
return Err(EmailError::invalid(format!("{field} email is empty")));
}
if self.email != email {
return Err(EmailError::invalid(format!(
"{field} email must not include leading or trailing whitespace"
)));
}
if email.contains(['\r', '\n']) {
return Err(EmailError::invalid(format!(
"{field} email contains a newline"
)));
}
if email.contains([' ', '\t', ',', ';', '<', '>', '"', '\'', '(', ')', '[', ']']) {
return Err(EmailError::invalid(format!(
"{field} email must be a single mailbox address"
)));
}
let mut parts = email.split('@');
let local = parts.next().unwrap_or_default();
let domain = parts.next().unwrap_or_default();
let has_extra_parts = parts.next().is_some();
if local.is_empty() || domain.is_empty() || has_extra_parts {
return Err(EmailError::invalid(format!(
"{field} email must be a single mailbox address"
)));
}
if let Some(name) = &self.name
&& name.contains(['\r', '\n'])
{
return Err(EmailError::invalid(format!(
"{field} name contains a newline"
)));
}
Ok(())
}
fn format_for_provider(&self) -> String {
match self.name.as_deref().filter(|name| !name.trim().is_empty()) {
Some(name) => format!("{name} <{}>", self.email),
None => self.email.clone(),
}
}
}
impl From<&str> for EmailAddress {
fn from(email: &str) -> Self {
Self::new(email)
}
}
impl From<String> for EmailAddress {
fn from(email: String) -> Self {
Self::new(email)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmailTag {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EmailTemplate {
Generic(GenericEmailTemplate),
}
impl EmailTemplate {
fn render(&self) -> EmailResult<RenderedEmail> {
match self {
Self::Generic(template) => template.render(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GenericEmailTemplate {
pub text: String,
pub html: String,
}
impl GenericEmailTemplate {
pub fn new(text: impl Into<String>, html: impl Into<String>) -> Self {
Self {
text: text.into(),
html: html.into(),
}
}
fn render(&self) -> EmailResult<RenderedEmail> {
if self.text.trim().is_empty() {
return Err(EmailError::invalid("generic email text is required"));
}
if self.html.trim().is_empty() {
return Err(EmailError::invalid("generic email html is required"));
}
Ok(RenderedEmail {
text: format!("Everruns\n\n{}", self.text),
html: wrap_generic_html(&self.html),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedEmail {
pub text: String,
pub html: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmailMessage {
pub to: Vec<EmailAddress>,
pub subject: String,
pub template: EmailTemplate,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<EmailTag>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
}
impl EmailMessage {
pub fn generic(
to: impl Into<EmailAddress>,
subject: impl Into<String>,
text: impl Into<String>,
html: impl Into<String>,
) -> Self {
Self {
to: vec![to.into()],
subject: subject.into(),
template: EmailTemplate::Generic(GenericEmailTemplate::new(text, html)),
tags: Vec::new(),
idempotency_key: None,
}
}
pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = Some(key.into());
self
}
pub fn with_tag(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.tags.push(EmailTag {
name: name.into(),
value: value.into(),
});
self
}
fn validate(&self) -> EmailResult<RenderedEmail> {
if self.to.is_empty() {
return Err(EmailError::invalid("at least one to recipient is required"));
}
if self.subject.trim().is_empty() {
return Err(EmailError::invalid("subject is required"));
}
for address in &self.to {
address.validate("to")?;
}
for tag in &self.tags {
if tag.name.trim().is_empty() {
return Err(EmailError::invalid("email tag name is required"));
}
if tag.value.trim().is_empty() {
return Err(EmailError::invalid("email tag value is required"));
}
}
if let Some(key) = &self.idempotency_key
&& key.len() > 256
{
return Err(EmailError::invalid(
"idempotency_key must be 256 characters or fewer",
));
}
if let Some(key) = &self.idempotency_key
&& key.chars().any(|ch| ch.is_ascii_control())
{
return Err(EmailError::invalid(
"idempotency_key must not contain control characters",
));
}
self.template.render()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SentEmail {
pub provider: &'static str,
pub id: String,
}
#[async_trait]
pub trait EmailSender: Send + Sync {
async fn send_email(&self, message: EmailMessage) -> EmailResult<SentEmail>;
fn name(&self) -> &'static str {
"EmailSender"
}
}
#[derive(Debug, Clone, Default)]
pub struct NoopEmailSender;
#[async_trait]
impl EmailSender for NoopEmailSender {
async fn send_email(&self, message: EmailMessage) -> EmailResult<SentEmail> {
message.validate()?;
Ok(SentEmail {
provider: "noop",
id: "noop".to_string(),
})
}
fn name(&self) -> &'static str {
"NoopEmailSender"
}
}
#[derive(Debug, Clone, Default)]
pub struct DisabledEmailSender;
#[async_trait]
impl EmailSender for DisabledEmailSender {
async fn send_email(&self, _message: EmailMessage) -> EmailResult<SentEmail> {
Err(EmailError::config("system email delivery is disabled"))
}
fn name(&self) -> &'static str {
"DisabledEmailSender"
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SystemEmailConfig {
Disabled,
Resend(ResendEmailConfig),
}
impl SystemEmailConfig {
pub fn from_env() -> EmailResult<Self> {
let provider = env_opt("EMAIL_PROVIDER").map(|provider| provider.to_ascii_lowercase());
match provider.as_deref() {
None | Some("disabled") => Ok(Self::Disabled),
Some("resend") => ResendEmailConfig::from_env().map(Self::Resend),
Some(provider) => Err(EmailError::config(format!(
"unsupported EMAIL_PROVIDER '{provider}'"
))),
}
}
pub fn into_sender(self) -> Arc<dyn EmailSender> {
self.into_sender_with_egress(Arc::new(crate::DirectEgressService::default()))
}
pub fn into_sender_with_egress(
self,
egress_service: Arc<dyn crate::EgressService>,
) -> Arc<dyn EmailSender> {
match self {
Self::Disabled => Arc::new(DisabledEmailSender),
Self::Resend(config) => Arc::new(ResendEmailSender::with_egress_service(
config,
egress_service,
)),
}
}
}
pub fn system_email_from() -> EmailAddress {
EmailAddress::named(SYSTEM_EMAIL_FROM, SYSTEM_EMAIL_FROM_NAME)
}
fn env_opt(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn wrap_generic_html(inner_html: &str) -> String {
format!(
r#"<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Everruns</title>
</head>
<body style="margin:0;background:#f6f7f9;color:#111827;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f6f7f9;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;">
<tr>
<td style="padding:24px 28px 8px;font-size:18px;font-weight:700;color:#111827;">Everruns</td>
</tr>
<tr>
<td style="padding:12px 28px 28px;font-size:15px;line-height:1.6;color:#1f2937;">{inner_html}</td>
</tr>
<tr>
<td style="padding:18px 28px;border-top:1px solid #e5e7eb;font-size:12px;line-height:1.5;color:#6b7280;">
This email was sent by Everruns.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"#
)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn generic_template_requires_text_content() {
let sender = NoopEmailSender;
let error = sender
.send_email(EmailMessage::generic(
"user@example.com",
"Empty",
"",
"<p>Hello</p>",
))
.await
.unwrap_err();
assert!(matches!(error, EmailError::InvalidRequest(_)));
assert!(error.to_string().contains("text"));
}
#[tokio::test]
async fn generic_template_wraps_branding() {
let message = EmailMessage::generic("user@example.com", "Hi", "Hello", "<p>Hello</p>");
let rendered = message.validate().unwrap();
assert!(rendered.text.starts_with("Everruns\n\nHello"));
assert!(rendered.html.contains("Everruns"));
assert!(rendered.html.contains("<p>Hello</p>"));
}
#[tokio::test]
async fn disabled_sender_returns_configuration_error() {
let sender = DisabledEmailSender;
let error = sender
.send_email(EmailMessage::generic(
"user@example.com",
"Hi",
"hello",
"<p>hello</p>",
))
.await
.unwrap_err();
assert!(matches!(error, EmailError::Configuration(_)));
assert!(error.to_string().contains("disabled"));
}
#[tokio::test]
async fn idempotency_key_rejects_control_characters() {
let sender = NoopEmailSender;
let error = sender
.send_email(
EmailMessage::generic("user@example.com", "Hi", "hello", "<p>hello</p>")
.with_idempotency_key("welcome\r\nX-Other: value"),
)
.await
.unwrap_err();
assert!(matches!(error, EmailError::InvalidRequest(_)));
assert!(error.to_string().contains("control characters"));
}
#[tokio::test]
async fn rejects_multi_recipient_in_single_to_field() {
let sender = NoopEmailSender;
let error = sender
.send_email(EmailMessage::generic(
"victim@example.com, attacker@example.com",
"Hi",
"hello",
"<p>hello</p>",
))
.await
.unwrap_err();
assert!(matches!(error, EmailError::InvalidRequest(_)));
assert!(error.to_string().contains("single mailbox"));
}
#[tokio::test]
async fn rejects_structured_mailbox_syntax_in_raw_email_field() {
let sender = NoopEmailSender;
let error = sender
.send_email(EmailMessage::generic(
"Victim <victim@example.com>",
"Hi",
"hello",
"<p>hello</p>",
))
.await
.unwrap_err();
assert!(matches!(error, EmailError::InvalidRequest(_)));
assert!(error.to_string().contains("single mailbox"));
}
#[tokio::test]
async fn rejects_email_with_surrounding_whitespace() {
let sender = NoopEmailSender;
let error = sender
.send_email(EmailMessage::generic(
" user@example.com ",
"Hi",
"hello",
"<p>hello</p>",
))
.await
.unwrap_err();
assert!(matches!(error, EmailError::InvalidRequest(_)));
assert!(error.to_string().contains("leading or trailing whitespace"));
}
}