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")
88 .unwrap_or_else(|_| "noreply@pylonsync.com".into()),
89 provider,
90 })
91 }
92
93 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
129fn 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}