mockforge_core/security/
email.rs

1//! Email service for security notifications
2//!
3//! Supports multiple email providers via HTTP APIs:
4//! - Postmark (via API)
5//! - Brevo (via API)
6//! - SendGrid (via API)
7//! - Disabled (logs only, for development/testing)
8
9use anyhow::{Context, Result};
10use serde::Serialize;
11use std::time::Duration;
12use tracing::{debug, error, info};
13
14/// Email provider type
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EmailProvider {
17    /// Postmark email service
18    Postmark,
19    /// Brevo (formerly Sendinblue) email service
20    Brevo,
21    /// SendGrid email service
22    SendGrid,
23    /// Email disabled (logs only)
24    Disabled,
25}
26
27impl EmailProvider {
28    /// Parse provider from string
29    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/// Email configuration
40#[derive(Debug, Clone)]
41pub struct EmailConfig {
42    /// Email provider to use
43    pub provider: EmailProvider,
44    /// From email address
45    pub from_email: String,
46    /// From name
47    pub from_name: String,
48    /// API key for email provider
49    pub api_key: Option<String>,
50}
51
52impl EmailConfig {
53    /// Create email config from environment variables
54    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/// Email message
69#[derive(Debug, Clone)]
70pub struct EmailMessage {
71    /// Recipient email address
72    pub to: String,
73    /// Email subject
74    pub subject: String,
75    /// HTML body content
76    pub html_body: String,
77    /// Plain text body content
78    pub text_body: String,
79}
80
81/// Email service for sending notifications
82pub struct EmailService {
83    config: EmailConfig,
84    client: reqwest::Client,
85}
86
87impl EmailService {
88    /// Create a new email service
89    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    /// Create email service from environment variables
99    pub fn from_env() -> Self {
100        Self::new(EmailConfig::from_env())
101    }
102
103    /// Send an email to a single recipient
104    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    /// Send email to multiple recipients
118    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    /// Send email via Postmark API
149    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    /// Send email via Brevo API
194    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    /// Send email via SendGrid API
253    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        // Should not fail when disabled
366        let result = service.send(message).await;
367        assert!(result.is_ok());
368    }
369}