mockforge_reporting/
email.rs

1//! Email notification system for chaos orchestration results
2
3use crate::pdf::ExecutionReport;
4use crate::{ReportingError, Result};
5use lettre::message::{header, Attachment, MultiPart, SinglePart};
6use lettre::transport::smtp::authentication::Credentials;
7use lettre::{Message, SmtpTransport, Transport};
8use serde::{Deserialize, Serialize};
9use std::fs;
10
11/// Email configuration
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct EmailConfig {
14    pub smtp_host: String,
15    pub smtp_port: u16,
16    pub username: String,
17    pub password: String,
18    pub from_address: String,
19    pub from_name: String,
20}
21
22/// Email report
23#[derive(Debug, Clone)]
24pub struct EmailReport {
25    pub subject: String,
26    pub recipients: Vec<String>,
27    pub html_body: String,
28    pub text_body: String,
29    pub pdf_attachment: Option<Vec<u8>>,
30}
31
32/// Email notifier
33pub struct EmailNotifier {
34    config: EmailConfig,
35    transport: SmtpTransport,
36}
37
38impl EmailNotifier {
39    /// Create a new email notifier
40    pub fn new(config: EmailConfig) -> Result<Self> {
41        let creds = Credentials::new(config.username.clone(), config.password.clone());
42
43        let transport = SmtpTransport::relay(&config.smtp_host)
44            .map_err(|e| ReportingError::Email(e.to_string()))?
45            .credentials(creds)
46            .port(config.smtp_port)
47            .build();
48
49        Ok(Self { config, transport })
50    }
51
52    /// Send email report
53    pub fn send(&self, email_report: &EmailReport) -> Result<()> {
54        let from = format!("{} <{}>", self.config.from_name, self.config.from_address);
55
56        let mut message_builder = Message::builder()
57            .from(
58                from.parse()
59                    .map_err(|e| ReportingError::Email(format!("Invalid from address: {}", e)))?,
60            )
61            .subject(&email_report.subject);
62
63        // Add recipients
64        for recipient in &email_report.recipients {
65            message_builder = message_builder.to(recipient
66                .parse()
67                .map_err(|e| ReportingError::Email(format!("Invalid recipient: {}", e)))?);
68        }
69
70        // Build multipart message
71        let mut multipart = MultiPart::alternative()
72            .singlepart(
73                SinglePart::builder()
74                    .header(header::ContentType::TEXT_PLAIN)
75                    .body(email_report.text_body.clone()),
76            )
77            .singlepart(
78                SinglePart::builder()
79                    .header(header::ContentType::TEXT_HTML)
80                    .body(email_report.html_body.clone()),
81            );
82
83        // Add PDF attachment if provided
84        if let Some(ref pdf_data) = email_report.pdf_attachment {
85            let attachment = Attachment::new("report.pdf".to_string())
86                .body(pdf_data.clone(), "application/pdf".parse().unwrap());
87            multipart = MultiPart::mixed().multipart(multipart).singlepart(attachment);
88        }
89
90        let email = message_builder
91            .multipart(multipart)
92            .map_err(|e| ReportingError::Email(e.to_string()))?;
93
94        self.transport.send(&email).map_err(|e| ReportingError::Email(e.to_string()))?;
95
96        Ok(())
97    }
98
99    /// Generate and send execution report
100    pub fn send_execution_report(
101        &self,
102        report: &ExecutionReport,
103        recipients: Vec<String>,
104        include_pdf: bool,
105    ) -> Result<()> {
106        let subject =
107            format!("Chaos Test Report: {} - {}", report.orchestration_name, report.status);
108
109        let html_body = self.generate_html_report(report);
110        let text_body = self.generate_text_report(report);
111
112        let pdf_attachment = if include_pdf {
113            Some(self.generate_pdf_attachment(report)?)
114        } else {
115            None
116        };
117
118        let email_report = EmailReport {
119            subject,
120            recipients,
121            html_body,
122            text_body,
123            pdf_attachment,
124        };
125
126        self.send(&email_report)
127    }
128
129    /// Generate HTML report body
130    fn generate_html_report(&self, report: &ExecutionReport) -> String {
131        format!(
132            r#"
133<!DOCTYPE html>
134<html>
135<head>
136    <style>
137        body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
138        .header {{ background: #2c3e50; color: white; padding: 20px; }}
139        .content {{ padding: 20px; }}
140        .status-badge {{ padding: 5px 10px; border-radius: 3px; font-weight: bold; }}
141        .success {{ background: #27ae60; color: white; }}
142        .failure {{ background: #e74c3c; color: white; }}
143        .metrics {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; margin: 20px 0; }}
144        .metric-card {{ border: 1px solid #ddd; padding: 15px; border-radius: 5px; }}
145        .metric-value {{ font-size: 24px; font-weight: bold; color: #2c3e50; }}
146        .metric-label {{ font-size: 12px; color: #7f8c8d; }}
147        table {{ width: 100%; border-collapse: collapse; margin: 20px 0; }}
148        th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }}
149        th {{ background: #f8f9fa; font-weight: bold; }}
150    </style>
151</head>
152<body>
153    <div class="header">
154        <h1>🌩️ Chaos Orchestration Report</h1>
155        <p>{}</p>
156    </div>
157
158    <div class="content">
159        <h2>Summary</h2>
160        <p>
161            <span class="status-badge {}">{}</span>
162        </p>
163        <p>
164            <strong>Duration:</strong> {}s<br>
165            <strong>Started:</strong> {}<br>
166            <strong>Ended:</strong> {}
167        </p>
168
169        <h2>Execution Metrics</h2>
170        <div class="metrics">
171            <div class="metric-card">
172                <div class="metric-value">{}</div>
173                <div class="metric-label">Total Steps</div>
174            </div>
175            <div class="metric-card">
176                <div class="metric-value">{}</div>
177                <div class="metric-label">Completed Steps</div>
178            </div>
179            <div class="metric-card">
180                <div class="metric-value">{:.2}%</div>
181                <div class="metric-label">Error Rate</div>
182            </div>
183            <div class="metric-card">
184                <div class="metric-value">{:.2}ms</div>
185                <div class="metric-label">Avg Latency</div>
186            </div>
187        </div>
188
189        {}
190
191        {}
192
193        <hr>
194        <p style="font-size: 12px; color: #7f8c8d;">
195            Generated by MockForge on {}<br>
196            <a href="https://github.com/your-org/mockforge">View Documentation</a>
197        </p>
198    </div>
199</body>
200</html>
201"#,
202            report.orchestration_name,
203            if report.failed_steps == 0 {
204                "success"
205            } else {
206                "failure"
207            },
208            report.status,
209            report.duration_seconds,
210            report.start_time.format("%Y-%m-%d %H:%M:%S UTC"),
211            report.end_time.format("%Y-%m-%d %H:%M:%S UTC"),
212            report.total_steps,
213            report.completed_steps,
214            report.metrics.error_rate * 100.0,
215            report.metrics.avg_latency_ms,
216            self.generate_failures_html(&report.failures),
217            self.generate_recommendations_html(&report.recommendations),
218            chrono::Utc::now().format("%Y-%m-%d %H:%M UTC")
219        )
220    }
221
222    /// Generate failures section HTML
223    fn generate_failures_html(&self, failures: &[crate::pdf::FailureDetail]) -> String {
224        if failures.is_empty() {
225            return String::new();
226        }
227
228        let mut html = String::from(
229            "<h2>Failures</h2><table><tr><th>Step</th><th>Error</th><th>Time</th></tr>",
230        );
231
232        for failure in failures {
233            html.push_str(&format!(
234                "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
235                failure.step_name,
236                failure.error_message,
237                failure.timestamp.format("%H:%M:%S")
238            ));
239        }
240
241        html.push_str("</table>");
242        html
243    }
244
245    /// Generate recommendations section HTML
246    fn generate_recommendations_html(&self, recommendations: &[String]) -> String {
247        if recommendations.is_empty() {
248            return String::new();
249        }
250
251        let mut html = String::from("<h2>Recommendations</h2><ul>");
252
253        for rec in recommendations {
254            html.push_str(&format!("<li>{}</li>", rec));
255        }
256
257        html.push_str("</ul>");
258        html
259    }
260
261    /// Generate plain text report
262    fn generate_text_report(&self, report: &ExecutionReport) -> String {
263        format!(
264            "CHAOS ORCHESTRATION REPORT\n\
265             ========================\n\n\
266             Orchestration: {}\n\
267             Status: {}\n\
268             Duration: {}s\n\
269             Started: {}\n\
270             Ended: {}\n\n\
271             EXECUTION SUMMARY\n\
272             -----------------\n\
273             Total Steps: {}\n\
274             Completed: {}\n\
275             Failed: {}\n\n\
276             METRICS\n\
277             -------\n\
278             Total Requests: {}\n\
279             Error Rate: {:.2}%\n\
280             Avg Latency: {:.2}ms\n\
281             P95 Latency: {:.2}ms\n\n\
282             Generated by MockForge\n",
283            report.orchestration_name,
284            report.status,
285            report.duration_seconds,
286            report.start_time.format("%Y-%m-%d %H:%M:%S UTC"),
287            report.end_time.format("%Y-%m-%d %H:%M:%S UTC"),
288            report.total_steps,
289            report.completed_steps,
290            report.failed_steps,
291            report.metrics.total_requests,
292            report.metrics.error_rate * 100.0,
293            report.metrics.avg_latency_ms,
294            report.metrics.p95_latency_ms
295        )
296    }
297
298    /// Generate PDF attachment
299    fn generate_pdf_attachment(&self, report: &ExecutionReport) -> Result<Vec<u8>> {
300        use crate::pdf::{PdfConfig, PdfReportGenerator};
301
302        let config = PdfConfig::default();
303        let generator = PdfReportGenerator::new(config);
304
305        let temp_path = "/tmp/mockforge_report.pdf";
306        generator.generate(report, temp_path)?;
307
308        let pdf_data = fs::read(temp_path)?;
309        fs::remove_file(temp_path)?;
310
311        Ok(pdf_data)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn test_html_generation() {
321        let config = EmailConfig {
322            smtp_host: "smtp.example.com".to_string(),
323            smtp_port: 587,
324            username: "user".to_string(),
325            password: "pass".to_string(),
326            from_address: "noreply@example.com".to_string(),
327            from_name: "MockForge".to_string(),
328        };
329
330        // Note: Can't test actual SMTP without a server
331        // This just tests the struct creation
332        assert_eq!(config.smtp_port, 587);
333    }
334}