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    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}