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")
88                .unwrap_or_else(|_| "noreply@pylonsync.com".into()),
89            provider,
90        })
91    }
92
93    /// Build the JSON body for the provider's API.
94    pub fn build_body(&self, to: &str, subject: &str, body: &str) -> String {
95        match self.provider {
96            HttpEmailProvider::SendGrid => serde_json::json!({
97                "personalizations": [{"to": [{"email": to}]}],
98                "from": {"email": self.from},
99                "subject": subject,
100                "content": [{"type": "text/plain", "value": body}]
101            })
102            .to_string(),
103            HttpEmailProvider::Resend => serde_json::json!({
104                "from": self.from,
105                "to": [to],
106                "subject": subject,
107                "text": body
108            })
109            .to_string(),
110            HttpEmailProvider::Webhook => serde_json::json!({
111                "to": to,
112                "from": self.from,
113                "subject": subject,
114                "body": body
115            })
116            .to_string(),
117        }
118    }
119}
120
121impl EmailTransport for HttpEmailTransport {
122    fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), EmailError> {
123        let body_json = self.build_body(to, subject, body);
124        post_json(&self.endpoint, &self.api_key, &body_json)
125            .map_err(|message| EmailError { message })
126    }
127}
128
129/// POST a JSON body with a Bearer token, using ureq with a 10s timeout.
130fn post_json(url: &str, api_key: &str, body: &str) -> Result<(), String> {
131    let agent = ureq::AgentBuilder::new()
132        .timeout_connect(std::time::Duration::from_secs(10))
133        .timeout_read(std::time::Duration::from_secs(10))
134        .timeout_write(std::time::Duration::from_secs(10))
135        .user_agent("pylon/0.1")
136        .build();
137
138    match agent
139        .post(url)
140        .set("Content-Type", "application/json")
141        .set("Authorization", &format!("Bearer {api_key}"))
142        .send_string(body)
143    {
144        Ok(_) => Ok(()),
145        Err(ureq::Error::Status(code, resp)) => {
146            let body = resp.into_string().unwrap_or_default();
147            Err(format!("HTTP {code}: {body}"))
148        }
149        Err(e) => Err(format!("HTTP error: {e}")),
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn console_transport_succeeds() {
159        let t = ConsoleTransport;
160        assert!(t.send("test@example.com", "Code", "123456").is_ok());
161    }
162
163    #[test]
164    fn sendgrid_body_format() {
165        let t = HttpEmailTransport {
166            endpoint: "https://api.sendgrid.com/v3/mail/send".into(),
167            api_key: "key".into(),
168            from: "noreply@test.com".into(),
169            provider: HttpEmailProvider::SendGrid,
170        };
171        let body = t.build_body("user@test.com", "Your code", "123456");
172        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
173        assert!(parsed["personalizations"][0]["to"][0]["email"] == "user@test.com");
174        assert!(parsed["from"]["email"] == "noreply@test.com");
175    }
176
177    #[test]
178    fn resend_body_format() {
179        let t = HttpEmailTransport {
180            endpoint: "https://api.resend.com/emails".into(),
181            api_key: "key".into(),
182            from: "noreply@test.com".into(),
183            provider: HttpEmailProvider::Resend,
184        };
185        let body = t.build_body("user@test.com", "Your code", "123456");
186        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
187        assert!(parsed["to"][0] == "user@test.com");
188        assert!(parsed["text"] == "123456");
189    }
190}