1use 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#[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#[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
32pub struct EmailNotifier {
34 config: EmailConfig,
35 transport: SmtpTransport,
36}
37
38impl EmailNotifier {
39 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(config.smtp_port, 587);
333 }
334}