Skip to main content

everruns_core/
email.rs

1// System email abstraction.
2//
3// Decision: Keep email delivery as a core, system-wide service rather than an
4// agent capability. Email sends are product/ops side effects owned by the host
5// application, not tools exposed to agents.
6// Decision: Keep provider details behind EmailSender so future SendGrid,
7// Cloudflare, SES, or SMTP implementations can reuse the same call sites.
8// Decision: Keep the sender fixed until product requirements justify
9// per-feature or per-tenant sender identity.
10
11use 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
20// Intentional current product sender. Keep this as `no-replay`, not `no-reply`,
21// until the verified sender identity changes.
22pub 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
158// Validates the caller-supplied body shared by every template. Each template
159// wraps this body differently, but all require non-empty text and HTML.
160fn 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// Unbranded transactional template styled to match the app (sharp corners,
171// grayscale surface, app foreground color). No Everruns wordmark, logo, or
172// footer link — use this when the surrounding flow already carries branding.
173#[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            // Neutral title keeps the minimal template free of any branding.
192            html: wrap_email_html("Notification", None, &self.html, None),
193        })
194    }
195}
196
197// Branded transactional template. Same app styling as `MinimalEmailTemplate`,
198// plus an inline Everruns logo + wordmark header and a footer linking to
199// everruns.com.
200#[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
409// Brand tokens mirrored from apps/ui/src/app/design-system.css. Email clients
410// cannot load the Geist webfont or resolve CSS variables, so the app's colors
411// and sharp-corner (0px radius) shapes are inlined here as literals. Keep these
412// in sync with the design system if the brand palette changes.
413const 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
423// Public Everruns surface used for branded links. Branded email intentionally
424// avoids remote images so self-hosted transactional mail does not leak recipient
425// open metadata to the upstream marketing domain or its CDN logs.
426const EVERRUNS_SITE_URL: &str = "https://everruns.com";
427
428// App-styled HTML shell shared by every template. `title` sets the document
429// `<title>` (kept neutral for the unbranded minimal template, since some clients
430// surface it in previews). `header` and `footer` are optional pre-rendered table
431// rows so branded templates can add a logo header and a footer link while the
432// minimal template stays bare.
433fn 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
466// Wordmark header row, linking to everruns.com without loading remote assets.
467fn 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
490// Inline SVG keeps the Basic template branded without fetching a remote image.
491// Geometry mirrors specs/brand.md: the rings' centroid is centered in the
492// viewBox so the mark stays balanced at small email-header sizes.
493fn 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
522// Footer row linking back to everruns.com, with a gold accent link.
523fn 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        // Body is passed through verbatim — no branding prefix.
578        assert_eq!(rendered.text, "Hello");
579        assert!(rendered.html.contains("<p>Hello</p>"));
580        // App styling: sharp corners and app surface/background colors.
581        assert!(rendered.html.contains("border-radius:0"));
582        assert!(rendered.html.contains(APP_BACKGROUND));
583        // No branding at all in the minimal template — not even the document
584        // <title>, which some clients surface in previews.
585        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        // Branded plain text: wordmark prefix and site link footer.
596        assert!(rendered.text.starts_with("Everruns\n\nHello"));
597        assert!(rendered.text.contains(EVERRUNS_SITE_URL));
598        // Branded HTML: shares the app shell, plus logo, wordmark, and link.
599        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}