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 Webhook,
62}
63
64impl HttpEmailTransport {
65 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 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
128fn 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}