Skip to main content

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    #[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/// Email configuration
41#[derive(Debug, Clone)]
42pub struct EmailConfig {
43    /// Email provider to use
44    pub provider: EmailProvider,
45    /// From email address
46    pub from_email: String,
47    /// From name
48    pub from_name: String,
49    /// API key for email provider
50    pub api_key: Option<String>,
51}
52
53impl EmailConfig {
54    /// Create email config from environment variables
55    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/// Email message
70#[derive(Debug, Clone)]
71pub struct EmailMessage {
72    /// Recipient email address
73    pub to: String,
74    /// Email subject
75    pub subject: String,
76    /// HTML body content
77    pub html_body: String,
78    /// Plain text body content
79    pub text_body: String,
80}
81
82/// Email service for sending notifications
83pub struct EmailService {
84    config: EmailConfig,
85    client: reqwest::Client,
86}
87
88impl EmailService {
89    /// Create a new email service
90    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    /// Create email service from environment variables
100    pub fn from_env() -> Self {
101        Self::new(EmailConfig::from_env())
102    }
103
104    /// Send an email to a single recipient
105    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    /// Send email to multiple recipients
119    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    /// Send email via Postmark API
150    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    /// Send email via Brevo API
196    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    /// Send email via SendGrid API
256    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        // Should not fail when disabled
369        let result = service.send(message).await;
370        assert!(result.is_ok());
371    }
372}