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