1use anyhow::Result;
2use serde_json::Value;
3use sqlx::PgPool;
4use std::sync::Arc;
5use stormchaser_tls::TlsReloader;
6
7#[cfg(feature = "email")]
8use crate::handler::{fetch_outputs, fetch_run_context, fetch_step_instance};
9#[cfg(feature = "email")]
10use stormchaser_model::dsl::{self, EmailBackend};
11#[cfg(feature = "email")]
12use stormchaser_model::workflow;
13#[cfg(feature = "email")]
14use tracing::info;
15
16#[cfg(feature = "email")]
17use super::{complete_email_step, fail_email_step, ses, smtp};
18
19#[cfg(feature = "email")]
20pub async fn handle_test_report_email(
22 run_id: stormchaser_model::RunId,
23 step_id: stormchaser_model::StepInstanceId,
24 spec: Value,
25 pool: PgPool,
26 nats_client: async_nats::Client,
27 tls_reloader: Arc<TlsReloader>,
28) -> Result<()> {
29 let spec: stormchaser_model::dsl::TestReportEmailSpec = serde_json::from_value(spec)?;
30
31 info!("Sending test report email for run {}", run_id);
32
33 let instance = fetch_step_instance(step_id, &pool).await?;
35 let machine =
36 crate::step_machine::StepMachine::<crate::step_machine::state::Pending>::from_instance(
37 instance,
38 );
39 let _ = machine
40 .start("test-report-email".to_string(), &mut *pool.acquire().await?)
41 .await?;
42
43 let reports = fetch_test_reports(run_id, &spec, &pool).await?;
45
46 let run_context: workflow::RunContext = fetch_run_context(run_id, &pool).await?;
48 let outputs: Value = fetch_outputs(run_id, &pool).await?;
49
50 let template_ctx = serde_json::json!({
51 "inputs": run_context.inputs,
52 "steps": outputs,
53 "run": {
54 "id": run_id.to_string(),
55 },
56 "reports": reports,
57 });
58
59 let rendered_body = render_test_report_body(&spec, &template_ctx)?;
61
62 let backend = spec.backend.clone().unwrap_or(EmailBackend::Smtp);
63
64 match backend {
65 EmailBackend::Ses => {
66 send_test_report_via_ses(run_id, step_id, &spec, rendered_body, pool, nats_client).await
67 }
68 EmailBackend::Smtp => {
69 send_test_report_via_smtp(
70 run_id,
71 step_id,
72 &spec,
73 rendered_body,
74 pool,
75 nats_client,
76 tls_reloader,
77 )
78 .await
79 }
80 }
81}
82
83#[cfg(feature = "email")]
84async fn fetch_test_reports(
85 run_id: stormchaser_model::RunId,
86 spec: &dsl::TestReportEmailSpec,
87 pool: &PgPool,
88) -> Result<Vec<Value>> {
89 let all_summaries = crate::db::get_test_summaries_for_run(pool, run_id.into_inner()).await?;
90 let filtered_summaries = if let Some(name) = &spec.report_name {
91 all_summaries
92 .into_iter()
93 .filter(|s| &s.report_name == name)
94 .collect::<Vec<_>>()
95 } else {
96 all_summaries
97 };
98
99 let mut reports = Vec::new();
100 for summary in filtered_summaries {
101 let cases =
102 crate::db::get_test_cases_for_report(pool, run_id.into_inner(), &summary.report_name)
103 .await?;
104 reports.push(serde_json::json!({
105 "summary": summary,
106 "cases": cases
107 }));
108 }
109 Ok(reports)
110}
111
112#[cfg(feature = "email")]
113fn render_test_report_body(
114 spec: &dsl::TestReportEmailSpec,
115 template_ctx: &Value,
116) -> Result<String> {
117 use minijinja::Environment;
118 let default_template = r#"
119 <html>
120 <head>
121 <style>
122 body { font-family: sans-serif; color: #333; }
123 .report { margin-bottom: 30px; border: 1px solid #ddd; padding: 15px; border-radius: 5px; }
124 .summary { display: flex; gap: 20px; background: #f9f9f9; padding: 10px; margin-bottom: 10px; }
125 .stat { text-align: center; }
126 .stat-value { font-size: 20px; font-weight: bold; }
127 .stat-label { font-size: 12px; color: #666; }
128 .passed { color: #28a745; }
129 .failed { color: #dc3545; }
130 .skipped { color: #ffc107; }
131 .error { color: #6f42c1; }
132 table { width: 100%; border-collapse: collapse; }
133 th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; }
134 tr.fail-row { background: #fff5f5; }
135 </style>
136 </head>
137 <body>
138 <h1>Workflow Test Report: {{ run.id }}</h1>
139 {% for report in reports %}
140 <div class="report">
141 <h2>Report: {{ report.summary.report_name }}</h2>
142 <div class="summary">
143 <div class="stat"><div class="stat-value">{{ report.summary.total_tests }}</div><div class="stat-label">Total</div></div>
144 <div class="stat"><div class="stat-value passed">{{ report.summary.passed }}</div><div class="stat-label">Passed</div></div>
145 <div class="stat"><div class="stat-value failed">{{ report.summary.failed }}</div><div class="stat-label">Failed</div></div>
146 <div class="stat"><div class="stat-value error">{{ report.summary.errors }}</div><div class="stat-label">Errors</div></div>
147 <div class="stat"><div class="stat-value skipped">{{ report.summary.skipped }}</div><div class="stat-label">Skipped</div></div>
148 <div class="stat"><div class="stat-value">{{ report.summary.duration_ms }}ms</div><div class="stat-label">Duration</div></div>
149 </div>
150 {% if report.summary.failed > 0 or report.summary.errors > 0 %}
151 <h3>Failures</h3>
152 <table>
153 <thead>
154 <tr><th>Suite</th><th>Case</th><th>Status</th><th>Message</th></tr>
155 </thead>
156 <tbody>
157 {% for case in report.cases %}
158 {% if case.status == 'failed' or case.status == 'error' %}
159 <tr class="fail-row">
160 <td>{{ case.test_suite or "Default" }}</td>
161 <td>{{ case.test_case }}</td>
162 <td class="{{ case.status }}">{{ case.status }}</td>
163 <td>{{ case.message or "" }}</td>
164 </tr>
165 {% endif %}
166 {% endfor %}
167 </tbody>
168 </table>
169 {% else %}
170 <p class="passed">All tests passed!</p>
171 {% endif %}
172 </div>
173 {% endfor %}
174 </body>
175 </html>
176 "#;
177
178 let template_str = spec.template.as_deref().unwrap_or(default_template);
179 let env = Environment::new();
180 env.render_str(template_str, template_ctx)
181 .map_err(|e| anyhow::anyhow!("Failed to render test report email body: {:?}", e))
182}
183
184#[cfg(feature = "email")]
185async fn send_test_report_via_ses(
186 run_id: stormchaser_model::RunId,
187 step_id: stormchaser_model::StepInstanceId,
188 spec: &dsl::TestReportEmailSpec,
189 rendered_body: String,
190 pool: PgPool,
191 nats_client: async_nats::Client,
192) -> Result<()> {
193 #[cfg(feature = "aws-ses")]
194 {
195 match ses::send_email_ses(
196 spec.from.clone(),
197 spec.to.clone(),
198 None,
199 None,
200 spec.subject.clone(),
201 rendered_body,
202 true, spec.ses_region.clone(),
204 spec.ses_role_arn.clone(),
205 spec.ses_configuration_set_name.clone(),
206 run_id,
207 )
208 .await
209 {
210 Ok(_) => {
211 info!(
212 "Test report email sent via SES successfully for step {}",
213 step_id
214 );
215 complete_email_step(run_id, step_id, pool, nats_client).await
216 }
217 Err(e) => {
218 let error_msg = format!("Failed to send test report email via SES: {:?}", e);
219 fail_email_step(run_id, step_id, error_msg, pool, nats_client).await
220 }
221 }
222 }
223 #[cfg(not(feature = "aws-ses"))]
224 {
225 let _ = (run_id, step_id, spec, rendered_body, pool, nats_client);
226 anyhow::bail!("SES backend requested but 'aws-ses' feature is not enabled.");
227 }
228}
229
230#[cfg(feature = "email")]
231async fn send_test_report_via_smtp(
232 run_id: stormchaser_model::RunId,
233 step_id: stormchaser_model::StepInstanceId,
234 spec: &dsl::TestReportEmailSpec,
235 rendered_body: String,
236 pool: PgPool,
237 nats_client: async_nats::Client,
238 _tls_reloader: Arc<TlsReloader>,
239) -> Result<()> {
240 use lettre::message::header::ContentType;
241 use lettre::{Message, Transport};
242
243 let mut builder = Message::builder()
245 .from(spec.from.parse()?)
246 .subject(spec.subject.clone())
247 .header(ContentType::TEXT_HTML);
248
249 for to in &spec.to {
250 builder = builder.to(to.parse()?);
251 }
252
253 let message = builder.body(rendered_body)?;
254
255 let smtp_params = smtp::SmtpParams {
256 server: spec.smtp_server.clone().unwrap_or_else(|| {
257 std::env::var("SMTP_SERVER").unwrap_or_else(|_| "localhost".to_string())
258 }),
259 port: spec.smtp_port.unwrap_or_else(|| {
260 std::env::var("SMTP_PORT")
261 .ok()
262 .and_then(|p| p.parse().ok())
263 .unwrap_or(25)
264 }),
265 username: spec
266 .smtp_username
267 .clone()
268 .or_else(|| std::env::var("SMTP_USERNAME").ok()),
269 password: spec
270 .smtp_password
271 .clone()
272 .or_else(|| std::env::var("SMTP_PASSWORD").ok()),
273 use_tls: spec
274 .smtp_use_tls
275 .unwrap_or_else(|| std::env::var("SMTP_USE_TLS").unwrap_or_default() == "true"),
276 use_mtls: spec
277 .smtp_use_mtls
278 .unwrap_or_else(|| std::env::var("SMTP_USE_MTLS").unwrap_or_default() == "true"),
279 };
280
281 let mailer = smtp::build_smtp_transport(smtp_params)?;
282 match mailer.send(&message) {
283 Ok(_) => {
284 info!("Test report email sent successfully for step {}", step_id);
285 complete_email_step(run_id, step_id, pool, nats_client).await
286 }
287 Err(e) => {
288 let error_msg = format!("Failed to send test report email: {:?}", e);
289 fail_email_step(run_id, step_id, error_msg, pool, nats_client).await
290 }
291 }
292}
293
294#[cfg(not(feature = "email"))]
295pub async fn handle_test_report_email(
297 _run_id: stormchaser_model::RunId,
298 _step_id: stormchaser_model::StepInstanceId,
299 _spec: Value,
300 _pool: PgPool,
301 _nats_client: async_nats::Client,
302 __tls_reloader: Arc<TlsReloader>,
303) -> Result<()> {
304 anyhow::bail!("Email support is not enabled. Enable 'email' feature.")
305}
306
307#[cfg(all(test, feature = "email"))]
308mod tests {
309 use super::*;
310 use serde_json::json;
311 use stormchaser_model::dsl::specs::{EmailBackend, TestReportEmailSpec};
312 use stormchaser_model::test_report::TestCaseStatus;
313
314 #[test]
315 #[cfg(feature = "email")]
316 fn test_render_test_report_body_default() {
317 let spec = TestReportEmailSpec {
318 from: "sender@paninfracon.net".to_string(),
319 to: vec!["receiver@paninfracon.net".to_string()],
320 subject: "Test Report".to_string(),
321 template: None,
322 report_name: None,
323 backend: Some(EmailBackend::Smtp),
324 smtp_server: None,
325 smtp_port: None,
326 smtp_username: None,
327 smtp_password: None,
328 smtp_use_tls: None,
329 smtp_use_mtls: None,
330 ses_region: None,
331 ses_role_arn: None,
332 ses_configuration_set_name: None,
333 };
334
335 let template_ctx = json!({
336 "run": { "id": "test-run-id" },
337 "reports": [
338 {
339 "summary": {
340 "report_name": "JUnit",
341 "total_tests": 10,
342 "passed": 8,
343 "failed": 1,
344 "errors": 1,
345 "skipped": 0,
346 "duration_ms": 1234
347 },
348 "cases": [
349 {
350 "test_suite": "suite1",
351 "test_case": "case1",
352 "status": TestCaseStatus::Passed,
353 "message": null
354 },
355 {
356 "test_suite": "suite1",
357 "test_case": "case2",
358 "status": TestCaseStatus::Failed,
359 "message": "Failure message"
360 },
361 {
362 "test_suite": "suite2",
363 "test_case": "case3",
364 "status": TestCaseStatus::Error,
365 "message": "Error message"
366 }
367 ]
368 }
369 ]
370 });
371
372 let rendered = render_test_report_body(&spec, &template_ctx).unwrap();
373 assert!(rendered.contains("Workflow Test Report: test-run-id"));
374 assert!(rendered.contains("Report: JUnit"));
375 assert!(rendered.contains("8")); assert!(rendered.contains("1")); assert!(rendered.contains("Failure message"));
378 assert!(rendered.contains("Error message"));
379 }
380
381 #[test]
382 #[cfg(feature = "email")]
383 fn test_render_test_report_body_custom() {
384 let spec = TestReportEmailSpec {
385 from: "sender@paninfracon.net".to_string(),
386 to: vec!["receiver@paninfracon.net".to_string()],
387 subject: "Test Report".to_string(),
388 template: Some("Custom: {{ run.id }}".to_string()),
389 report_name: None,
390 backend: Some(EmailBackend::Smtp),
391 smtp_server: None,
392 smtp_port: None,
393 smtp_username: None,
394 smtp_password: None,
395 smtp_use_tls: None,
396 smtp_use_mtls: None,
397 ses_region: None,
398 ses_role_arn: None,
399 ses_configuration_set_name: None,
400 };
401
402 let template_ctx = json!({
403 "run": { "id": "custom-run-id" },
404 "reports": []
405 });
406
407 let rendered = render_test_report_body(&spec, &template_ctx).unwrap();
408 assert_eq!(rendered.trim(), "Custom: custom-run-id");
409 }
410}