Skip to main content

pylon_auth/
email.rs

1//! Pluggable email transport for auth flows (magic codes, invitations, etc.).
2
3// ---------------------------------------------------------------------------
4// Email transport trait
5// ---------------------------------------------------------------------------
6
7/// Pluggable email delivery backend.
8///
9/// Implemented for SMTP, SendGrid, SES, Resend, etc.
10/// The `ConsoleTransport` prints to stderr for local development.
11pub trait EmailTransport: Send + Sync {
12    fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), EmailError>;
13}
14
15#[derive(Debug, Clone)]
16pub struct EmailError {
17    pub message: String,
18}
19
20impl std::fmt::Display for EmailError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(f, "EmailError: {}", self.message)
23    }
24}
25
26impl std::error::Error for EmailError {}
27
28// ---------------------------------------------------------------------------
29// Console transport (dev mode)
30// ---------------------------------------------------------------------------
31
32/// Prints emails to stderr. Used in development.
33pub struct ConsoleTransport;
34
35impl EmailTransport for ConsoleTransport {
36    fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), EmailError> {
37        eprintln!("[email] To: {to}");
38        eprintln!("[email] Subject: {subject}");
39        eprintln!("[email] Body: {body}");
40        eprintln!("[email] ---");
41        Ok(())
42    }
43}
44
45// ---------------------------------------------------------------------------
46// HTTP transport (SendGrid, Resend, generic webhook)
47// ---------------------------------------------------------------------------
48
49/// Email delivery via HTTP POST (SendGrid, Resend, or any HTTP endpoint).
50pub struct HttpEmailTransport {
51    pub endpoint: String,
52    pub api_key: String,
53    pub from: String,
54    pub provider: HttpEmailProvider,
55}
56
57#[derive(Debug, Clone, Copy)]
58pub enum HttpEmailProvider {
59    SendGrid,
60    Resend,
61    Webhook,
62}
63
64impl HttpEmailTransport {
65    /// Create from environment variables.
66    ///
67    /// Reads: PYLON_EMAIL_PROVIDER (sendgrid|resend|webhook),
68    /// PYLON_EMAIL_API_KEY, PYLON_EMAIL_FROM, PYLON_EMAIL_ENDPOINT
69    pub fn from_env() -> Option<Self> {
70        let provider_str = std::env::var("PYLON_EMAIL_PROVIDER").ok()?;
71        let provider = match provider_str.as_str() {
72            "sendgrid" => HttpEmailProvider::SendGrid,
73            "resend" => HttpEmailProvider::Resend,
74            "webhook" => HttpEmailProvider::Webhook,
75            _ => return None,
76        };
77
78        let endpoint = match provider {
79            HttpEmailProvider::SendGrid => "https://api.sendgrid.com/v3/mail/send".to_string(),
80            HttpEmailProvider::Resend => "https://api.resend.com/emails".to_string(),
81            HttpEmailProvider::Webhook => std::env::var("PYLON_EMAIL_ENDPOINT").ok()?,
82        };
83
84        Some(Self {
85            endpoint,
86            api_key: std::env::var("PYLON_EMAIL_API_KEY").ok()?,
87            from: std::env::var("PYLON_EMAIL_FROM").unwrap_or_else(|_| "noreply@pylon.dev".into()),
88            provider,
89        })
90    }
91
92    /// Build the JSON body for the provider's API.
93    pub fn build_body(&self, to: &str, subject: &str, body: &str) -> String {
94        match self.provider {
95            HttpEmailProvider::SendGrid => serde_json::json!({
96                "personalizations": [{"to": [{"email": to}]}],
97                "from": {"email": self.from},
98                "subject": subject,
99                "content": [{"type": "text/plain", "value": body}]
100            })
101            .to_string(),
102            HttpEmailProvider::Resend => serde_json::json!({
103                "from": self.from,
104                "to": [to],
105                "subject": subject,
106                "text": body
107            })
108            .to_string(),
109            HttpEmailProvider::Webhook => serde_json::json!({
110                "to": to,
111                "from": self.from,
112                "subject": subject,
113                "body": body
114            })
115            .to_string(),
116        }
117    }
118}
119
120impl EmailTransport for HttpEmailTransport {
121    fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), EmailError> {
122        let body_json = self.build_body(to, subject, body);
123        post_json(&self.endpoint, &self.api_key, &body_json)
124            .map_err(|message| EmailError { message })
125    }
126}
127
128/// POST a JSON body with a Bearer token, using ureq with a 10s timeout.
129fn post_json(url: &str, api_key: &str, body: &str) -> Result<(), String> {
130    let agent = ureq::AgentBuilder::new()
131        .timeout_connect(std::time::Duration::from_secs(10))
132        .timeout_read(std::time::Duration::from_secs(10))
133        .timeout_write(std::time::Duration::from_secs(10))
134        .user_agent("pylon/0.1")
135        .build();
136
137    match agent
138        .post(url)
139        .set("Content-Type", "application/json")
140        .set("Authorization", &format!("Bearer {api_key}"))
141        .send_string(body)
142    {
143        Ok(_) => Ok(()),
144        Err(ureq::Error::Status(code, resp)) => {
145            let body = resp.into_string().unwrap_or_default();
146            Err(format!("HTTP {code}: {body}"))
147        }
148        Err(e) => Err(format!("HTTP error: {e}")),
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn console_transport_succeeds() {
158        let t = ConsoleTransport;
159        assert!(t.send("test@example.com", "Code", "123456").is_ok());
160    }
161
162    #[test]
163    fn sendgrid_body_format() {
164        let t = HttpEmailTransport {
165            endpoint: "https://api.sendgrid.com/v3/mail/send".into(),
166            api_key: "key".into(),
167            from: "noreply@test.com".into(),
168            provider: HttpEmailProvider::SendGrid,
169        };
170        let body = t.build_body("user@test.com", "Your code", "123456");
171        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
172        assert!(parsed["personalizations"][0]["to"][0]["email"] == "user@test.com");
173        assert!(parsed["from"]["email"] == "noreply@test.com");
174    }
175
176    #[test]
177    fn resend_body_format() {
178        let t = HttpEmailTransport {
179            endpoint: "https://api.resend.com/emails".into(),
180            api_key: "key".into(),
181            from: "noreply@test.com".into(),
182            provider: HttpEmailProvider::Resend,
183        };
184        let body = t.build_body("user@test.com", "Your code", "123456");
185        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
186        assert!(parsed["to"][0] == "user@test.com");
187        assert!(parsed["text"] == "123456");
188    }
189}