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 Minimal(MinimalEmailTemplate),
146 Basic(BasicEmailTemplate),
147}
148
149impl EmailTemplate {
150 fn render(&self) -> EmailResult<RenderedEmail> {
151 match self {
152 Self::Minimal(template) => template.render(),
153 Self::Basic(template) => template.render(),
154 }
155 }
156}
157
158fn validate_body(text: &str, html: &str) -> EmailResult<()> {
161 if text.trim().is_empty() {
162 return Err(EmailError::invalid("email text is required"));
163 }
164 if html.trim().is_empty() {
165 return Err(EmailError::invalid("email html is required"));
166 }
167 Ok(())
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct MinimalEmailTemplate {
175 pub text: String,
176 pub html: String,
177}
178
179impl MinimalEmailTemplate {
180 pub fn new(text: impl Into<String>, html: impl Into<String>) -> Self {
181 Self {
182 text: text.into(),
183 html: html.into(),
184 }
185 }
186
187 fn render(&self) -> EmailResult<RenderedEmail> {
188 validate_body(&self.text, &self.html)?;
189 Ok(RenderedEmail {
190 text: self.text.clone(),
191 html: wrap_email_html("Notification", None, &self.html, None),
193 })
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct BasicEmailTemplate {
202 pub text: String,
203 pub html: String,
204}
205
206impl BasicEmailTemplate {
207 pub fn new(text: impl Into<String>, html: impl Into<String>) -> Self {
208 Self {
209 text: text.into(),
210 html: html.into(),
211 }
212 }
213
214 fn render(&self) -> EmailResult<RenderedEmail> {
215 validate_body(&self.text, &self.html)?;
216 Ok(RenderedEmail {
217 text: format!(
218 "Everruns\n\n{}\n\n—\nSent by Everruns · {EVERRUNS_SITE_URL}",
219 self.text
220 ),
221 html: wrap_email_html(
222 "Everruns",
223 Some(&branded_header()),
224 &self.html,
225 Some(&branded_footer()),
226 ),
227 })
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct RenderedEmail {
233 pub text: String,
234 pub html: String,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct EmailMessage {
239 pub to: Vec<EmailAddress>,
240 pub subject: String,
241 pub template: EmailTemplate,
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub tags: Vec<EmailTag>,
244 #[serde(skip_serializing_if = "Option::is_none")]
245 pub idempotency_key: Option<String>,
246}
247
248impl EmailMessage {
249 pub fn minimal(
250 to: impl Into<EmailAddress>,
251 subject: impl Into<String>,
252 text: impl Into<String>,
253 html: impl Into<String>,
254 ) -> Self {
255 Self {
256 to: vec![to.into()],
257 subject: subject.into(),
258 template: EmailTemplate::Minimal(MinimalEmailTemplate::new(text, html)),
259 tags: Vec::new(),
260 idempotency_key: None,
261 }
262 }
263
264 pub fn basic(
265 to: impl Into<EmailAddress>,
266 subject: impl Into<String>,
267 text: impl Into<String>,
268 html: impl Into<String>,
269 ) -> Self {
270 Self {
271 to: vec![to.into()],
272 subject: subject.into(),
273 template: EmailTemplate::Basic(BasicEmailTemplate::new(text, html)),
274 tags: Vec::new(),
275 idempotency_key: None,
276 }
277 }
278
279 pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
280 self.idempotency_key = Some(key.into());
281 self
282 }
283
284 pub fn with_tag(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
285 self.tags.push(EmailTag {
286 name: name.into(),
287 value: value.into(),
288 });
289 self
290 }
291
292 fn validate(&self) -> EmailResult<RenderedEmail> {
293 if self.to.is_empty() {
294 return Err(EmailError::invalid("at least one to recipient is required"));
295 }
296 if self.subject.trim().is_empty() {
297 return Err(EmailError::invalid("subject is required"));
298 }
299 for address in &self.to {
300 address.validate("to")?;
301 }
302 for tag in &self.tags {
303 if tag.name.trim().is_empty() {
304 return Err(EmailError::invalid("email tag name is required"));
305 }
306 if tag.value.trim().is_empty() {
307 return Err(EmailError::invalid("email tag value is required"));
308 }
309 }
310 if let Some(key) = &self.idempotency_key
311 && key.len() > 256
312 {
313 return Err(EmailError::invalid(
314 "idempotency_key must be 256 characters or fewer",
315 ));
316 }
317 if let Some(key) = &self.idempotency_key
318 && key.chars().any(|ch| ch.is_ascii_control())
319 {
320 return Err(EmailError::invalid(
321 "idempotency_key must not contain control characters",
322 ));
323 }
324 self.template.render()
325 }
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct SentEmail {
330 pub provider: &'static str,
331 pub id: String,
332}
333
334#[async_trait]
335pub trait EmailSender: Send + Sync {
336 async fn send_email(&self, message: EmailMessage) -> EmailResult<SentEmail>;
337
338 fn name(&self) -> &'static str {
339 "EmailSender"
340 }
341}
342
343#[derive(Debug, Clone, Default)]
344pub struct NoopEmailSender;
345
346#[async_trait]
347impl EmailSender for NoopEmailSender {
348 async fn send_email(&self, message: EmailMessage) -> EmailResult<SentEmail> {
349 message.validate()?;
350 Ok(SentEmail {
351 provider: "noop",
352 id: "noop".to_string(),
353 })
354 }
355
356 fn name(&self) -> &'static str {
357 "NoopEmailSender"
358 }
359}
360
361#[derive(Debug, Clone, Default)]
362pub struct DisabledEmailSender;
363
364#[async_trait]
365impl EmailSender for DisabledEmailSender {
366 async fn send_email(&self, _message: EmailMessage) -> EmailResult<SentEmail> {
367 Err(EmailError::config("system email delivery is disabled"))
368 }
369
370 fn name(&self) -> &'static str {
371 "DisabledEmailSender"
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub enum SystemEmailConfig {
377 Disabled,
378 Resend(ResendEmailConfig),
379}
380
381impl SystemEmailConfig {
382 pub fn from_env() -> EmailResult<Self> {
383 let provider = env_opt("EMAIL_PROVIDER").map(|provider| provider.to_ascii_lowercase());
384 match provider.as_deref() {
385 None | Some("disabled") => Ok(Self::Disabled),
386 Some("resend") => ResendEmailConfig::from_env().map(Self::Resend),
387 Some(provider) => Err(EmailError::config(format!(
388 "unsupported EMAIL_PROVIDER '{provider}'"
389 ))),
390 }
391 }
392
393 pub fn into_sender(self) -> Arc<dyn EmailSender> {
394 match self {
395 Self::Disabled => Arc::new(DisabledEmailSender),
396 Self::Resend(config) => Arc::new(ResendEmailSender::new(config)),
397 }
398 }
399}
400
401pub fn system_email_from() -> EmailAddress {
402 EmailAddress::named(SYSTEM_EMAIL_FROM, SYSTEM_EMAIL_FROM_NAME)
403}
404
405fn env_opt(name: &str) -> Option<String> {
406 std::env::var(name).ok().filter(|value| !value.is_empty())
407}
408
409const BRAND_NAVY: &str = "#0A1636";
414const BRAND_GOLD: &str = "#D4A43A";
415const APP_BACKGROUND: &str = "#fafafa";
416const APP_SURFACE: &str = "#ffffff";
417const APP_BORDER: &str = "#e0e0e0";
418const APP_FOREGROUND: &str = "#1a1a1a";
419const APP_MUTED_FOREGROUND: &str = "#737373";
420const EMAIL_FONT_STACK: &str =
421 "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
422
423const EVERRUNS_SITE_URL: &str = "https://everruns.com";
427
428fn wrap_email_html(
434 title: &str,
435 header: Option<&str>,
436 inner_html: &str,
437 footer: Option<&str>,
438) -> String {
439 let header = header.unwrap_or("");
440 let footer = footer.unwrap_or("");
441 format!(
442 r#"<!doctype html>
443<html>
444<head>
445 <meta charset="utf-8">
446 <meta name="viewport" content="width=device-width, initial-scale=1">
447 <title>{title}</title>
448</head>
449<body style="margin:0;background:{APP_BACKGROUND};color:{APP_FOREGROUND};font-family:{EMAIL_FONT_STACK};">
450 <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:{APP_BACKGROUND};padding:32px 16px;">
451 <tr>
452 <td align="center">
453 <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;background:{APP_SURFACE};border:1px solid {APP_BORDER};border-radius:0;">
454{header} <tr>
455 <td style="padding:28px;font-size:15px;line-height:1.6;color:{APP_FOREGROUND};">{inner_html}</td>
456 </tr>
457{footer} </table>
458 </td>
459 </tr>
460 </table>
461</body>
462</html>"#
463 )
464}
465
466fn branded_header() -> String {
468 format!(
469 r#" <tr>
470 <td style="padding:24px 28px;border-bottom:1px solid {APP_BORDER};">
471 <table role="presentation" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
472 <tr>
473 <td width="32" style="width:32px;padding:0 10px 0 0;vertical-align:middle;">
474 <a href="{EVERRUNS_SITE_URL}" aria-label="Everruns" style="display:inline-block;text-decoration:none;">
475{logo}
476 </a>
477 </td>
478 <td style="vertical-align:middle;">
479 <a href="{EVERRUNS_SITE_URL}" style="text-decoration:none;display:inline-block;font-size:18px;font-weight:700;color:{BRAND_NAVY};letter-spacing:0;">Everruns</a>
480 </td>
481 </tr>
482 </table>
483 </td>
484 </tr>
485"#,
486 logo = branded_logo_svg()
487 )
488}
489
490fn branded_logo_svg() -> String {
494 format!(
495 r##" <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 512 512" role="img" aria-label="Everruns logo" style="display:block;">
496 <defs>
497 <linearGradient id="emailLogoTop" gradientUnits="userSpaceOnUse" x1="256" y1="63.64" x2="256" y2="256.00">
498 <stop offset="0.00" stop-color="{BRAND_NAVY}"/>
499 <stop offset="0.70" stop-color="{BRAND_NAVY}"/>
500 <stop offset="1.00" stop-color="{BRAND_GOLD}"/>
501 </linearGradient>
502 <linearGradient id="emailLogoLeft" gradientUnits="userSpaceOnUse" x1="89.41" y1="352.18" x2="256" y2="256.00">
503 <stop offset="0.00" stop-color="#081C3F"/>
504 <stop offset="0.70" stop-color="#081C3F"/>
505 <stop offset="1.00" stop-color="{BRAND_GOLD}"/>
506 </linearGradient>
507 <linearGradient id="emailLogoRight" gradientUnits="userSpaceOnUse" x1="422.59" y1="352.18" x2="256" y2="256.00">
508 <stop offset="0.00" stop-color="#0B1233"/>
509 <stop offset="0.70" stop-color="#0B1233"/>
510 <stop offset="1.00" stop-color="{BRAND_GOLD}"/>
511 </linearGradient>
512 </defs>
513 <g fill="none" stroke-width="18" stroke-linecap="round" stroke-linejoin="round">
514 <circle cx="256" cy="183.64" r="120" stroke="url(#emailLogoTop)"/>
515 <circle cx="193.33" cy="292.18" r="120" stroke="url(#emailLogoLeft)"/>
516 <circle cx="318.67" cy="292.18" r="120" stroke="url(#emailLogoRight)"/>
517 </g>
518 </svg>"##
519 )
520}
521
522fn branded_footer() -> String {
524 format!(
525 r#" <tr>
526 <td style="padding:18px 28px;border-top:1px solid {APP_BORDER};font-size:12px;line-height:1.5;color:{APP_MUTED_FOREGROUND};">
527 Sent by <a href="{EVERRUNS_SITE_URL}" style="color:{BRAND_GOLD};text-decoration:none;font-weight:600;">everruns.com</a>
528 </td>
529 </tr>
530"#
531 )
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 #[tokio::test]
539 async fn minimal_template_requires_text_content() {
540 let sender = NoopEmailSender;
541 let error = sender
542 .send_email(EmailMessage::minimal(
543 "user@example.com",
544 "Empty",
545 "",
546 "<p>Hello</p>",
547 ))
548 .await
549 .unwrap_err();
550
551 assert!(matches!(error, EmailError::InvalidRequest(_)));
552 assert!(error.to_string().contains("text"));
553 }
554
555 #[tokio::test]
556 async fn minimal_template_requires_html_content() {
557 let sender = NoopEmailSender;
558 let error = sender
559 .send_email(EmailMessage::minimal(
560 "user@example.com",
561 "Empty",
562 "Hello",
563 " ",
564 ))
565 .await
566 .unwrap_err();
567
568 assert!(matches!(error, EmailError::InvalidRequest(_)));
569 assert!(error.to_string().contains("html"));
570 }
571
572 #[tokio::test]
573 async fn minimal_template_is_app_styled_without_branding() {
574 let message = EmailMessage::minimal("user@example.com", "Hi", "Hello", "<p>Hello</p>");
575 let rendered = message.validate().unwrap();
576
577 assert_eq!(rendered.text, "Hello");
579 assert!(rendered.html.contains("<p>Hello</p>"));
580 assert!(rendered.html.contains("border-radius:0"));
582 assert!(rendered.html.contains(APP_BACKGROUND));
583 assert!(!rendered.html.contains("Everruns"));
586 assert!(!rendered.html.contains(EVERRUNS_SITE_URL));
587 assert!(!rendered.html.contains("Sent by"));
588 }
589
590 #[tokio::test]
591 async fn basic_template_adds_branding_without_remote_images() {
592 let message = EmailMessage::basic("user@example.com", "Hi", "Hello", "<p>Hello</p>");
593 let rendered = message.validate().unwrap();
594
595 assert!(rendered.text.starts_with("Everruns\n\nHello"));
597 assert!(rendered.text.contains(EVERRUNS_SITE_URL));
598 assert!(rendered.html.contains("<p>Hello</p>"));
600 assert!(rendered.html.contains("border-radius:0"));
601 assert!(rendered.html.contains("Everruns"));
602 assert!(rendered.html.contains("<svg"));
603 assert!(rendered.html.contains(r#"aria-label="Everruns logo""#));
604 assert!(rendered.html.contains("emailLogoTop"));
605 assert!(!rendered.html.contains("<img"));
606 assert!(
607 rendered
608 .html
609 .contains(&format!(r#"href="{EVERRUNS_SITE_URL}""#))
610 );
611 }
612
613 #[tokio::test]
614 async fn disabled_sender_returns_configuration_error() {
615 let sender = DisabledEmailSender;
616 let error = sender
617 .send_email(EmailMessage::minimal(
618 "user@example.com",
619 "Hi",
620 "hello",
621 "<p>hello</p>",
622 ))
623 .await
624 .unwrap_err();
625
626 assert!(matches!(error, EmailError::Configuration(_)));
627 assert!(error.to_string().contains("disabled"));
628 }
629
630 #[tokio::test]
631 async fn idempotency_key_rejects_control_characters() {
632 let sender = NoopEmailSender;
633 let error = sender
634 .send_email(
635 EmailMessage::minimal("user@example.com", "Hi", "hello", "<p>hello</p>")
636 .with_idempotency_key("welcome\r\nX-Other: value"),
637 )
638 .await
639 .unwrap_err();
640
641 assert!(matches!(error, EmailError::InvalidRequest(_)));
642 assert!(error.to_string().contains("control characters"));
643 }
644
645 #[tokio::test]
646 async fn rejects_multi_recipient_in_single_to_field() {
647 let sender = NoopEmailSender;
648 let error = sender
649 .send_email(EmailMessage::minimal(
650 "victim@example.com, attacker@example.com",
651 "Hi",
652 "hello",
653 "<p>hello</p>",
654 ))
655 .await
656 .unwrap_err();
657
658 assert!(matches!(error, EmailError::InvalidRequest(_)));
659 assert!(error.to_string().contains("single mailbox"));
660 }
661
662 #[tokio::test]
663 async fn rejects_structured_mailbox_syntax_in_raw_email_field() {
664 let sender = NoopEmailSender;
665 let error = sender
666 .send_email(EmailMessage::minimal(
667 "Victim <victim@example.com>",
668 "Hi",
669 "hello",
670 "<p>hello</p>",
671 ))
672 .await
673 .unwrap_err();
674
675 assert!(matches!(error, EmailError::InvalidRequest(_)));
676 assert!(error.to_string().contains("single mailbox"));
677 }
678
679 #[tokio::test]
680 async fn rejects_email_with_surrounding_whitespace() {
681 let sender = NoopEmailSender;
682 let error = sender
683 .send_email(EmailMessage::minimal(
684 " user@example.com ",
685 "Hi",
686 "hello",
687 "<p>hello</p>",
688 ))
689 .await
690 .unwrap_err();
691
692 assert!(matches!(error, EmailError::InvalidRequest(_)));
693 assert!(error.to_string().contains("leading or trailing whitespace"));
694 }
695}