1use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use std::sync::Arc;
14use thiserror::Error;
15
16pub mod resend;
17
18pub use resend::{ResendEmailConfig, ResendEmailSender};
19
20pub const SYSTEM_EMAIL_FROM: &str = "no-replay@everruns.com";
23const SYSTEM_EMAIL_FROM_NAME: &str = "Everruns";
24
25pub type EmailResult<T> = std::result::Result<T, EmailError>;
26
27#[derive(Debug, Error)]
28pub enum EmailError {
29 #[error("Email configuration error: {0}")]
30 Configuration(String),
31
32 #[error("Invalid email request: {0}")]
33 InvalidRequest(String),
34
35 #[error("Email provider transport error: {0}")]
36 Transport(String),
37
38 #[error("Email provider error ({provider}, status {status}): {body}")]
39 Provider {
40 provider: &'static str,
41 status: u16,
42 body: String,
43 },
44}
45
46impl EmailError {
47 fn config(message: impl Into<String>) -> Self {
48 Self::Configuration(message.into())
49 }
50
51 fn invalid(message: impl Into<String>) -> Self {
52 Self::InvalidRequest(message.into())
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct EmailAddress {
58 pub email: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub name: Option<String>,
61}
62
63impl EmailAddress {
64 pub fn new(email: impl Into<String>) -> Self {
65 Self {
66 email: email.into(),
67 name: None,
68 }
69 }
70
71 pub fn named(email: impl Into<String>, name: impl Into<String>) -> Self {
72 Self {
73 email: email.into(),
74 name: Some(name.into()),
75 }
76 }
77
78 fn validate(&self, field: &str) -> EmailResult<()> {
79 let email = self.email.trim();
80 if email.is_empty() {
81 return Err(EmailError::invalid(format!("{field} email is empty")));
82 }
83 if self.email != email {
84 return Err(EmailError::invalid(format!(
85 "{field} email must not include leading or trailing whitespace"
86 )));
87 }
88 if email.contains(['\r', '\n']) {
89 return Err(EmailError::invalid(format!(
90 "{field} email contains a newline"
91 )));
92 }
93 if email.contains([' ', '\t', ',', ';', '<', '>', '"', '\'', '(', ')', '[', ']']) {
94 return Err(EmailError::invalid(format!(
95 "{field} email must be a single mailbox address"
96 )));
97 }
98 let mut parts = email.split('@');
99 let local = parts.next().unwrap_or_default();
100 let domain = parts.next().unwrap_or_default();
101 let has_extra_parts = parts.next().is_some();
102 if local.is_empty() || domain.is_empty() || has_extra_parts {
103 return Err(EmailError::invalid(format!(
104 "{field} email must be a single mailbox address"
105 )));
106 }
107 if let Some(name) = &self.name
108 && name.contains(['\r', '\n'])
109 {
110 return Err(EmailError::invalid(format!(
111 "{field} name contains a newline"
112 )));
113 }
114 Ok(())
115 }
116
117 fn format_for_provider(&self) -> String {
118 match self.name.as_deref().filter(|name| !name.trim().is_empty()) {
119 Some(name) => format!("{name} <{}>", self.email),
120 None => self.email.clone(),
121 }
122 }
123}
124
125impl From<&str> for EmailAddress {
126 fn from(email: &str) -> Self {
127 Self::new(email)
128 }
129}
130
131impl From<String> for EmailAddress {
132 fn from(email: String) -> Self {
133 Self::new(email)
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct EmailTag {
139 pub name: String,
140 pub value: String,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub enum EmailTemplate {
145 Generic(GenericEmailTemplate),
146}
147
148impl EmailTemplate {
149 fn render(&self) -> EmailResult<RenderedEmail> {
150 match self {
151 Self::Generic(template) => template.render(),
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct GenericEmailTemplate {
158 pub text: String,
159 pub html: String,
160}
161
162impl GenericEmailTemplate {
163 pub fn new(text: impl Into<String>, html: impl Into<String>) -> Self {
164 Self {
165 text: text.into(),
166 html: html.into(),
167 }
168 }
169
170 fn render(&self) -> EmailResult<RenderedEmail> {
171 if self.text.trim().is_empty() {
172 return Err(EmailError::invalid("generic email text is required"));
173 }
174 if self.html.trim().is_empty() {
175 return Err(EmailError::invalid("generic email html is required"));
176 }
177
178 Ok(RenderedEmail {
179 text: format!("Everruns\n\n{}", self.text),
180 html: wrap_generic_html(&self.html),
181 })
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct RenderedEmail {
187 pub text: String,
188 pub html: String,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct EmailMessage {
193 pub to: Vec<EmailAddress>,
194 pub subject: String,
195 pub template: EmailTemplate,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 pub tags: Vec<EmailTag>,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub idempotency_key: Option<String>,
200}
201
202impl EmailMessage {
203 pub fn generic(
204 to: impl Into<EmailAddress>,
205 subject: impl Into<String>,
206 text: impl Into<String>,
207 html: impl Into<String>,
208 ) -> Self {
209 Self {
210 to: vec![to.into()],
211 subject: subject.into(),
212 template: EmailTemplate::Generic(GenericEmailTemplate::new(text, html)),
213 tags: Vec::new(),
214 idempotency_key: None,
215 }
216 }
217
218 pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
219 self.idempotency_key = Some(key.into());
220 self
221 }
222
223 pub fn with_tag(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
224 self.tags.push(EmailTag {
225 name: name.into(),
226 value: value.into(),
227 });
228 self
229 }
230
231 fn validate(&self) -> EmailResult<RenderedEmail> {
232 if self.to.is_empty() {
233 return Err(EmailError::invalid("at least one to recipient is required"));
234 }
235 if self.subject.trim().is_empty() {
236 return Err(EmailError::invalid("subject is required"));
237 }
238 for address in &self.to {
239 address.validate("to")?;
240 }
241 for tag in &self.tags {
242 if tag.name.trim().is_empty() {
243 return Err(EmailError::invalid("email tag name is required"));
244 }
245 if tag.value.trim().is_empty() {
246 return Err(EmailError::invalid("email tag value is required"));
247 }
248 }
249 if let Some(key) = &self.idempotency_key
250 && key.len() > 256
251 {
252 return Err(EmailError::invalid(
253 "idempotency_key must be 256 characters or fewer",
254 ));
255 }
256 if let Some(key) = &self.idempotency_key
257 && key.chars().any(|ch| ch.is_ascii_control())
258 {
259 return Err(EmailError::invalid(
260 "idempotency_key must not contain control characters",
261 ));
262 }
263 self.template.render()
264 }
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct SentEmail {
269 pub provider: &'static str,
270 pub id: String,
271}
272
273#[async_trait]
274pub trait EmailSender: Send + Sync {
275 async fn send_email(&self, message: EmailMessage) -> EmailResult<SentEmail>;
276
277 fn name(&self) -> &'static str {
278 "EmailSender"
279 }
280}
281
282#[derive(Debug, Clone, Default)]
283pub struct NoopEmailSender;
284
285#[async_trait]
286impl EmailSender for NoopEmailSender {
287 async fn send_email(&self, message: EmailMessage) -> EmailResult<SentEmail> {
288 message.validate()?;
289 Ok(SentEmail {
290 provider: "noop",
291 id: "noop".to_string(),
292 })
293 }
294
295 fn name(&self) -> &'static str {
296 "NoopEmailSender"
297 }
298}
299
300#[derive(Debug, Clone, Default)]
301pub struct DisabledEmailSender;
302
303#[async_trait]
304impl EmailSender for DisabledEmailSender {
305 async fn send_email(&self, _message: EmailMessage) -> EmailResult<SentEmail> {
306 Err(EmailError::config("system email delivery is disabled"))
307 }
308
309 fn name(&self) -> &'static str {
310 "DisabledEmailSender"
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum SystemEmailConfig {
316 Disabled,
317 Resend(ResendEmailConfig),
318}
319
320impl SystemEmailConfig {
321 pub fn from_env() -> EmailResult<Self> {
322 let provider = env_opt("EMAIL_PROVIDER").map(|provider| provider.to_ascii_lowercase());
323 match provider.as_deref() {
324 None | Some("disabled") => Ok(Self::Disabled),
325 Some("resend") => ResendEmailConfig::from_env().map(Self::Resend),
326 Some(provider) => Err(EmailError::config(format!(
327 "unsupported EMAIL_PROVIDER '{provider}'"
328 ))),
329 }
330 }
331
332 pub fn into_sender(self) -> Arc<dyn EmailSender> {
333 self.into_sender_with_egress(Arc::new(crate::DirectEgressService::default()))
334 }
335
336 pub fn into_sender_with_egress(
337 self,
338 egress_service: Arc<dyn crate::EgressService>,
339 ) -> Arc<dyn EmailSender> {
340 match self {
341 Self::Disabled => Arc::new(DisabledEmailSender),
342 Self::Resend(config) => Arc::new(ResendEmailSender::with_egress_service(
343 config,
344 egress_service,
345 )),
346 }
347 }
348}
349
350pub fn system_email_from() -> EmailAddress {
351 EmailAddress::named(SYSTEM_EMAIL_FROM, SYSTEM_EMAIL_FROM_NAME)
352}
353
354fn env_opt(name: &str) -> Option<String> {
355 std::env::var(name).ok().filter(|value| !value.is_empty())
356}
357
358fn wrap_generic_html(inner_html: &str) -> String {
359 format!(
360 r#"<!doctype html>
361<html>
362<head>
363 <meta charset="utf-8">
364 <meta name="viewport" content="width=device-width, initial-scale=1">
365 <title>Everruns</title>
366</head>
367<body style="margin:0;background:#f6f7f9;color:#111827;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
368 <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f6f7f9;padding:32px 16px;">
369 <tr>
370 <td align="center">
371 <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;">
372 <tr>
373 <td style="padding:24px 28px 8px;font-size:18px;font-weight:700;color:#111827;">Everruns</td>
374 </tr>
375 <tr>
376 <td style="padding:12px 28px 28px;font-size:15px;line-height:1.6;color:#1f2937;">{inner_html}</td>
377 </tr>
378 <tr>
379 <td style="padding:18px 28px;border-top:1px solid #e5e7eb;font-size:12px;line-height:1.5;color:#6b7280;">
380 This email was sent by Everruns.
381 </td>
382 </tr>
383 </table>
384 </td>
385 </tr>
386 </table>
387</body>
388</html>"#
389 )
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[tokio::test]
397 async fn generic_template_requires_text_content() {
398 let sender = NoopEmailSender;
399 let error = sender
400 .send_email(EmailMessage::generic(
401 "user@example.com",
402 "Empty",
403 "",
404 "<p>Hello</p>",
405 ))
406 .await
407 .unwrap_err();
408
409 assert!(matches!(error, EmailError::InvalidRequest(_)));
410 assert!(error.to_string().contains("text"));
411 }
412
413 #[tokio::test]
414 async fn generic_template_wraps_branding() {
415 let message = EmailMessage::generic("user@example.com", "Hi", "Hello", "<p>Hello</p>");
416 let rendered = message.validate().unwrap();
417
418 assert!(rendered.text.starts_with("Everruns\n\nHello"));
419 assert!(rendered.html.contains("Everruns"));
420 assert!(rendered.html.contains("<p>Hello</p>"));
421 }
422
423 #[tokio::test]
424 async fn disabled_sender_returns_configuration_error() {
425 let sender = DisabledEmailSender;
426 let error = sender
427 .send_email(EmailMessage::generic(
428 "user@example.com",
429 "Hi",
430 "hello",
431 "<p>hello</p>",
432 ))
433 .await
434 .unwrap_err();
435
436 assert!(matches!(error, EmailError::Configuration(_)));
437 assert!(error.to_string().contains("disabled"));
438 }
439
440 #[tokio::test]
441 async fn idempotency_key_rejects_control_characters() {
442 let sender = NoopEmailSender;
443 let error = sender
444 .send_email(
445 EmailMessage::generic("user@example.com", "Hi", "hello", "<p>hello</p>")
446 .with_idempotency_key("welcome\r\nX-Other: value"),
447 )
448 .await
449 .unwrap_err();
450
451 assert!(matches!(error, EmailError::InvalidRequest(_)));
452 assert!(error.to_string().contains("control characters"));
453 }
454
455 #[tokio::test]
456 async fn rejects_multi_recipient_in_single_to_field() {
457 let sender = NoopEmailSender;
458 let error = sender
459 .send_email(EmailMessage::generic(
460 "victim@example.com, attacker@example.com",
461 "Hi",
462 "hello",
463 "<p>hello</p>",
464 ))
465 .await
466 .unwrap_err();
467
468 assert!(matches!(error, EmailError::InvalidRequest(_)));
469 assert!(error.to_string().contains("single mailbox"));
470 }
471
472 #[tokio::test]
473 async fn rejects_structured_mailbox_syntax_in_raw_email_field() {
474 let sender = NoopEmailSender;
475 let error = sender
476 .send_email(EmailMessage::generic(
477 "Victim <victim@example.com>",
478 "Hi",
479 "hello",
480 "<p>hello</p>",
481 ))
482 .await
483 .unwrap_err();
484
485 assert!(matches!(error, EmailError::InvalidRequest(_)));
486 assert!(error.to_string().contains("single mailbox"));
487 }
488
489 #[tokio::test]
490 async fn rejects_email_with_surrounding_whitespace() {
491 let sender = NoopEmailSender;
492 let error = sender
493 .send_email(EmailMessage::generic(
494 " user@example.com ",
495 "Hi",
496 "hello",
497 "<p>hello</p>",
498 ))
499 .await
500 .unwrap_err();
501
502 assert!(matches!(error, EmailError::InvalidRequest(_)));
503 assert!(error.to_string().contains("leading or trailing whitespace"));
504 }
505}