1pub 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
28pub 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
45pub 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 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 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
139fn 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 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 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}