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, Stack0, 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, Stack0, generic webhook)
47// ---------------------------------------------------------------------------
48
49/// Email delivery via HTTP POST (SendGrid, Resend, Stack0, 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    Stack0,
62    Webhook,
63}
64
65impl HttpEmailTransport {
66    /// Create from environment variables.
67    ///
68    /// Reads: PYLON_EMAIL_PROVIDER (sendgrid|resend|stack0|webhook),
69    /// PYLON_EMAIL_API_KEY, PYLON_EMAIL_FROM, PYLON_EMAIL_ENDPOINT
70    pub fn from_env() -> Option<Self> {
71        let provider_str = std::env::var("PYLON_EMAIL_PROVIDER").ok()?;
72        let provider = match provider_str.as_str() {
73            "sendgrid" => HttpEmailProvider::SendGrid,
74            "resend" => HttpEmailProvider::Resend,
75            "stack0" => HttpEmailProvider::Stack0,
76            "webhook" => HttpEmailProvider::Webhook,
77            _ => return None,
78        };
79
80        let endpoint = match provider {
81            HttpEmailProvider::SendGrid => "https://api.sendgrid.com/v3/mail/send".to_string(),
82            HttpEmailProvider::Resend => "https://api.resend.com/emails".to_string(),
83            HttpEmailProvider::Stack0 => "https://api.stack0.dev/mail/send".to_string(),
84            HttpEmailProvider::Webhook => std::env::var("PYLON_EMAIL_ENDPOINT").ok()?,
85        };
86
87        Some(Self {
88            endpoint,
89            api_key: std::env::var("PYLON_EMAIL_API_KEY").ok()?,
90            from: std::env::var("PYLON_EMAIL_FROM")
91                .unwrap_or_else(|_| "noreply@pylonsync.com".into()),
92            provider,
93        })
94    }
95
96    /// Build the JSON body for the provider's API.
97    pub fn build_body(&self, to: &str, subject: &str, body: &str) -> String {
98        match self.provider {
99            HttpEmailProvider::SendGrid => serde_json::json!({
100                "personalizations": [{"to": [{"email": to}]}],
101                "from": {"email": self.from},
102                "subject": subject,
103                "content": [{"type": "text/plain", "value": body}]
104            })
105            .to_string(),
106            HttpEmailProvider::Resend => serde_json::json!({
107                "from": self.from,
108                "to": [to],
109                "subject": subject,
110                "text": body
111            })
112            .to_string(),
113            HttpEmailProvider::Stack0 => serde_json::json!({
114                "from": self.from,
115                "to": [to],
116                "subject": subject,
117                "text": body
118            })
119            .to_string(),
120            HttpEmailProvider::Webhook => serde_json::json!({
121                "to": to,
122                "from": self.from,
123                "subject": subject,
124                "body": body
125            })
126            .to_string(),
127        }
128    }
129}
130
131impl EmailTransport for HttpEmailTransport {
132    fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), EmailError> {
133        let body_json = self.build_body(to, subject, body);
134        post_json(&self.endpoint, &self.api_key, &body_json)
135            .map_err(|message| EmailError { message })
136    }
137}
138
139/// POST a JSON body with a Bearer token, using ureq with a 10s timeout.
140fn post_json(url: &str, api_key: &str, body: &str) -> Result<(), String> {
141    let agent = ureq::AgentBuilder::new()
142        .timeout_connect(std::time::Duration::from_secs(10))
143        .timeout_read(std::time::Duration::from_secs(10))
144        .timeout_write(std::time::Duration::from_secs(10))
145        .user_agent("pylon/0.1")
146        .build();
147
148    match agent
149        .post(url)
150        .set("Content-Type", "application/json")
151        .set("Authorization", &format!("Bearer {api_key}"))
152        .send_string(body)
153    {
154        Ok(_) => Ok(()),
155        Err(ureq::Error::Status(code, resp)) => {
156            let body = resp.into_string().unwrap_or_default();
157            Err(format!("HTTP {code}: {body}"))
158        }
159        Err(e) => Err(format!("HTTP error: {e}")),
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn console_transport_succeeds() {
169        let t = ConsoleTransport;
170        assert!(t.send("test@example.com", "Code", "123456").is_ok());
171    }
172
173    #[test]
174    fn sendgrid_body_format() {
175        let t = HttpEmailTransport {
176            endpoint: "https://api.sendgrid.com/v3/mail/send".into(),
177            api_key: "key".into(),
178            from: "noreply@test.com".into(),
179            provider: HttpEmailProvider::SendGrid,
180        };
181        let body = t.build_body("user@test.com", "Your code", "123456");
182        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
183        assert!(parsed["personalizations"][0]["to"][0]["email"] == "user@test.com");
184        assert!(parsed["from"]["email"] == "noreply@test.com");
185    }
186
187    #[test]
188    fn resend_body_format() {
189        let t = HttpEmailTransport {
190            endpoint: "https://api.resend.com/emails".into(),
191            api_key: "key".into(),
192            from: "noreply@test.com".into(),
193            provider: HttpEmailProvider::Resend,
194        };
195        let body = t.build_body("user@test.com", "Your code", "123456");
196        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
197        assert!(parsed["to"][0] == "user@test.com");
198        assert!(parsed["text"] == "123456");
199    }
200
201    #[test]
202    fn stack0_body_format() {
203        let t = HttpEmailTransport {
204            endpoint: "https://api.stack0.dev/mail/send".into(),
205            api_key: "key".into(),
206            from: "noreply@test.com".into(),
207            provider: HttpEmailProvider::Stack0,
208        };
209        let body = t.build_body("user@test.com", "Your code", "123456");
210        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
211        assert_eq!(parsed["from"], "noreply@test.com");
212        assert_eq!(parsed["to"][0], "user@test.com");
213        assert_eq!(parsed["subject"], "Your code");
214        assert_eq!(parsed["text"], "123456");
215    }
216
217    #[test]
218    fn stack0_from_env_picks_correct_endpoint() {
219        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
220        // Snapshot + clear env vars we touch so this test is hermetic.
221        let prev_provider = std::env::var("PYLON_EMAIL_PROVIDER").ok();
222        let prev_key = std::env::var("PYLON_EMAIL_API_KEY").ok();
223        let prev_from = std::env::var("PYLON_EMAIL_FROM").ok();
224
225        std::env::set_var("PYLON_EMAIL_PROVIDER", "stack0");
226        std::env::set_var("PYLON_EMAIL_API_KEY", "sk_test_abc");
227        std::env::set_var("PYLON_EMAIL_FROM", "noreply@example.com");
228
229        let t = HttpEmailTransport::from_env().expect("should construct");
230        assert_eq!(t.endpoint, "https://api.stack0.dev/mail/send");
231        assert_eq!(t.from, "noreply@example.com");
232        assert!(matches!(t.provider, HttpEmailProvider::Stack0));
233
234        // Restore.
235        match prev_provider {
236            Some(v) => std::env::set_var("PYLON_EMAIL_PROVIDER", v),
237            None => std::env::remove_var("PYLON_EMAIL_PROVIDER"),
238        }
239        match prev_key {
240            Some(v) => std::env::set_var("PYLON_EMAIL_API_KEY", v),
241            None => std::env::remove_var("PYLON_EMAIL_API_KEY"),
242        }
243        match prev_from {
244            Some(v) => std::env::set_var("PYLON_EMAIL_FROM", v),
245            None => std::env::remove_var("PYLON_EMAIL_FROM"),
246        }
247    }
248
249    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
250}