1use anyhow::{Context, Result};
10use serde::Serialize;
11use std::time::Duration;
12use tracing::{debug, error, info};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EmailProvider {
17 Postmark,
19 Brevo,
21 SendGrid,
23 Disabled,
25}
26
27impl EmailProvider {
28 pub fn from_str(s: &str) -> Self {
30 match s.to_lowercase().as_str() {
31 "postmark" => EmailProvider::Postmark,
32 "brevo" | "sendinblue" => EmailProvider::Brevo,
33 "sendgrid" => EmailProvider::SendGrid,
34 _ => EmailProvider::Disabled,
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct EmailConfig {
42 pub provider: EmailProvider,
44 pub from_email: String,
46 pub from_name: String,
48 pub api_key: Option<String>,
50}
51
52impl EmailConfig {
53 pub fn from_env() -> Self {
55 let provider = std::env::var("EMAIL_PROVIDER").unwrap_or_else(|_| "disabled".to_string());
56
57 Self {
58 provider: EmailProvider::from_str(&provider),
59 from_email: std::env::var("EMAIL_FROM")
60 .unwrap_or_else(|_| "noreply@mockforge.dev".to_string()),
61 from_name: std::env::var("EMAIL_FROM_NAME")
62 .unwrap_or_else(|_| "MockForge Security".to_string()),
63 api_key: std::env::var("EMAIL_API_KEY").ok(),
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct EmailMessage {
71 pub to: String,
73 pub subject: String,
75 pub html_body: String,
77 pub text_body: String,
79}
80
81pub struct EmailService {
83 config: EmailConfig,
84 client: reqwest::Client,
85}
86
87impl EmailService {
88 pub fn new(config: EmailConfig) -> Self {
90 let client = reqwest::Client::builder()
91 .timeout(Duration::from_secs(10))
92 .build()
93 .expect("Failed to create HTTP client for email service");
94
95 Self { config, client }
96 }
97
98 pub fn from_env() -> Self {
100 Self::new(EmailConfig::from_env())
101 }
102
103 pub async fn send(&self, message: EmailMessage) -> Result<()> {
105 match &self.config.provider {
106 EmailProvider::Postmark => self.send_via_postmark(message).await,
107 EmailProvider::Brevo => self.send_via_brevo(message).await,
108 EmailProvider::SendGrid => self.send_via_sendgrid(message).await,
109 EmailProvider::Disabled => {
110 info!("Email disabled, would send: '{}' to {}", message.subject, message.to);
111 debug!("Email body (text): {}", message.text_body);
112 Ok(())
113 }
114 }
115 }
116
117 pub async fn send_to_multiple(
119 &self,
120 message: EmailMessage,
121 recipients: &[String],
122 ) -> Result<()> {
123 let mut errors = Vec::new();
124
125 for recipient in recipients {
126 let mut msg = message.clone();
127 msg.to = recipient.clone();
128
129 match self.send(msg).await {
130 Ok(()) => {
131 debug!("Email sent successfully to {}", recipient);
132 }
133 Err(e) => {
134 let error_msg = format!("Failed to send email to {}: {}", recipient, e);
135 error!("{}", error_msg);
136 errors.push(error_msg);
137 }
138 }
139 }
140
141 if !errors.is_empty() {
142 anyhow::bail!("Failed to send emails to some recipients: {}", errors.join("; "));
143 }
144
145 Ok(())
146 }
147
148 async fn send_via_postmark(&self, message: EmailMessage) -> Result<()> {
150 let api_key = self
151 .config
152 .api_key
153 .as_ref()
154 .context("Postmark requires EMAIL_API_KEY environment variable")?;
155
156 #[derive(Serialize)]
157 struct PostmarkRequest {
158 From: String,
159 To: String,
160 Subject: String,
161 HtmlBody: String,
162 TextBody: String,
163 }
164
165 let to_email = message.to.clone();
166 let request = PostmarkRequest {
167 From: format!("{} <{}>", self.config.from_name, self.config.from_email),
168 To: message.to,
169 Subject: message.subject,
170 HtmlBody: message.html_body,
171 TextBody: message.text_body,
172 };
173 let response = self
174 .client
175 .post("https://api.postmarkapp.com/email")
176 .header("X-Postmark-Server-Token", api_key)
177 .header("Content-Type", "application/json")
178 .json(&request)
179 .send()
180 .await
181 .context("Failed to send email via Postmark API")?;
182
183 let status = response.status();
184 if !status.is_success() {
185 let error_text = response.text().await.unwrap_or_default();
186 anyhow::bail!("Postmark API error ({}): {}", status, error_text);
187 }
188
189 info!("Email sent via Postmark to {}", to_email);
190 Ok(())
191 }
192
193 async fn send_via_brevo(&self, message: EmailMessage) -> Result<()> {
195 let api_key = self
196 .config
197 .api_key
198 .as_ref()
199 .context("Brevo requires EMAIL_API_KEY environment variable")?;
200
201 #[derive(Serialize)]
202 struct BrevoSender {
203 name: String,
204 email: String,
205 }
206
207 #[derive(Serialize)]
208 struct BrevoTo {
209 email: String,
210 }
211
212 #[derive(Serialize)]
213 struct BrevoRequest {
214 sender: BrevoSender,
215 to: Vec<BrevoTo>,
216 subject: String,
217 htmlContent: String,
218 textContent: String,
219 }
220
221 let to_email = message.to.clone();
222 let request = BrevoRequest {
223 sender: BrevoSender {
224 name: self.config.from_name.clone(),
225 email: self.config.from_email.clone(),
226 },
227 to: vec![BrevoTo { email: message.to }],
228 subject: message.subject,
229 htmlContent: message.html_body,
230 textContent: message.text_body,
231 };
232 let response = self
233 .client
234 .post("https://api.brevo.com/v3/smtp/email")
235 .header("api-key", api_key)
236 .header("Content-Type", "application/json")
237 .json(&request)
238 .send()
239 .await
240 .context("Failed to send email via Brevo API")?;
241
242 let status = response.status();
243 if !status.is_success() {
244 let error_text = response.text().await.unwrap_or_default();
245 anyhow::bail!("Brevo API error ({}): {}", status, error_text);
246 }
247
248 info!("Email sent via Brevo to {}", to_email);
249 Ok(())
250 }
251
252 async fn send_via_sendgrid(&self, message: EmailMessage) -> Result<()> {
254 let api_key = self
255 .config
256 .api_key
257 .as_ref()
258 .context("SendGrid requires EMAIL_API_KEY environment variable")?;
259
260 #[derive(Serialize)]
261 struct SendGridEmail {
262 email: String,
263 name: Option<String>,
264 }
265
266 #[derive(Serialize)]
267 struct SendGridContent {
268 #[serde(rename = "type")]
269 content_type: String,
270 value: String,
271 }
272
273 #[derive(Serialize)]
274 struct SendGridPersonalization {
275 to: Vec<SendGridEmail>,
276 subject: String,
277 }
278
279 #[derive(Serialize)]
280 struct SendGridRequest {
281 personalizations: Vec<SendGridPersonalization>,
282 from: SendGridEmail,
283 subject: String,
284 content: Vec<SendGridContent>,
285 }
286
287 let request = SendGridRequest {
288 personalizations: vec![SendGridPersonalization {
289 to: vec![SendGridEmail {
290 email: message.to.clone(),
291 name: None,
292 }],
293 subject: message.subject.clone(),
294 }],
295 from: SendGridEmail {
296 email: self.config.from_email.clone(),
297 name: Some(self.config.from_name.clone()),
298 },
299 subject: message.subject,
300 content: vec![
301 SendGridContent {
302 content_type: "text/plain".to_string(),
303 value: message.text_body,
304 },
305 SendGridContent {
306 content_type: "text/html".to_string(),
307 value: message.html_body,
308 },
309 ],
310 };
311
312 let to_email = message.to.clone();
313 let response = self
314 .client
315 .post("https://api.sendgrid.com/v3/mail/send")
316 .header("Authorization", format!("Bearer {}", api_key))
317 .header("Content-Type", "application/json")
318 .json(&request)
319 .send()
320 .await
321 .context("Failed to send email via SendGrid API")?;
322
323 let status = response.status();
324 if !status.is_success() {
325 let error_text = response.text().await.unwrap_or_default();
326 anyhow::bail!("SendGrid API error ({}): {}", status, error_text);
327 }
328
329 info!("Email sent via SendGrid to {}", to_email);
330 Ok(())
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn test_email_provider_from_str() {
340 assert_eq!(EmailProvider::from_str("postmark"), EmailProvider::Postmark);
341 assert_eq!(EmailProvider::from_str("brevo"), EmailProvider::Brevo);
342 assert_eq!(EmailProvider::from_str("sendinblue"), EmailProvider::Brevo);
343 assert_eq!(EmailProvider::from_str("sendgrid"), EmailProvider::SendGrid);
344 assert_eq!(EmailProvider::from_str("disabled"), EmailProvider::Disabled);
345 assert_eq!(EmailProvider::from_str("unknown"), EmailProvider::Disabled);
346 }
347
348 #[tokio::test]
349 async fn test_email_service_disabled() {
350 let config = EmailConfig {
351 provider: EmailProvider::Disabled,
352 from_email: "test@example.com".to_string(),
353 from_name: "Test".to_string(),
354 api_key: None,
355 };
356
357 let service = EmailService::new(config);
358 let message = EmailMessage {
359 to: "recipient@example.com".to_string(),
360 subject: "Test Subject".to_string(),
361 html_body: "<p>Test HTML</p>".to_string(),
362 text_body: "Test Text".to_string(),
363 };
364
365 let result = service.send(message).await;
367 assert!(result.is_ok());
368 }
369}