stormchaser_engine/handler/integrations/
approval.rs1use anyhow::Result;
2use serde_json::Value;
3use sqlx::PgPool;
4
5#[cfg(feature = "email")]
6#[cfg(feature = "email")]
7use stormchaser_model::workflow;
8
9pub async fn handle_approval_notification(
11 run_id: stormchaser_model::RunId,
12 step_id: stormchaser_model::StepInstanceId,
13 spec: Value,
14 pool: PgPool,
15 _nats_client: async_nats::Client,
16) -> Result<()> {
17 #[cfg(feature = "email")]
18 {
19 use lettre::message::header::ContentType;
20 use lettre::{Message, Transport};
21 use minijinja::Environment;
22 use tracing::info;
23
24 let spec: stormchaser_model::dsl::EmailSpec = serde_json::from_value(spec)?;
25 let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "test-secret".to_string());
26 let base_url =
27 std::env::var("SYSTEM_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
28
29 let (approve_link, reject_link) =
31 generate_approval_links(run_id, step_id, &secret, &base_url)?;
32
33 let run_context: workflow::RunContext =
35 crate::handler::fetch_run_context(run_id, &pool).await?;
36 let outputs: Value = crate::handler::fetch_outputs(run_id, &pool).await?;
37
38 let template_ctx = serde_json::json!({
39 "inputs": run_context.inputs,
40 "steps": outputs,
41 "run": {
42 "id": run_id.to_string(),
43 },
44 "step": {
45 "id": step_id.to_string(),
46 },
47 "approve_link": approve_link,
48 "reject_link": reject_link,
49 });
50
51 let env = Environment::new();
53 let rendered_body = env
54 .render_str(&spec.body, template_ctx)
55 .map_err(|e| anyhow::anyhow!("Failed to render approval email body: {:?}", e))?;
56
57 let mut builder = Message::builder()
59 .from(spec.from.parse()?)
60 .subject(spec.subject.clone());
61
62 for to in &spec.to {
63 builder = builder.to(to.parse()?);
64 }
65
66 let is_html = spec.html.unwrap_or(false);
67 let message = if is_html {
68 builder.header(ContentType::TEXT_HTML).body(rendered_body)?
69 } else {
70 builder
71 .header(ContentType::TEXT_PLAIN)
72 .body(rendered_body)?
73 };
74
75 let mailer = build_approval_mailer(&spec);
77 mailer.send(&message)?;
78 info!("Approval notification email sent for step {}", step_id);
79 }
80
81 #[cfg(not(feature = "email"))]
82 {
83 let _ = (run_id, step_id, spec, pool, _nats_client);
84 tracing::warn!("Approval notification requested but 'email' feature is not enabled.");
85 }
86
87 Ok(())
88}
89
90#[cfg(feature = "email")]
91fn generate_approval_links(
92 run_id: stormchaser_model::RunId,
93 step_id: stormchaser_model::StepInstanceId,
94 secret: &str,
95 base_url: &str,
96) -> Result<(String, String)> {
97 let approve_token = crate::hitl::generate_approval_token(run_id, step_id, "approve", secret)?;
98 let reject_token = crate::hitl::generate_approval_token(run_id, step_id, "reject", secret)?;
99
100 let approve_link = format!("{}/api/v1/approve-link/{}", base_url, approve_token);
101 let reject_link = format!("{}/api/v1/approve-link/{}", base_url, reject_token);
102 Ok((approve_link, reject_link))
103}
104
105#[cfg(feature = "email")]
106fn build_approval_mailer(spec: &stormchaser_model::dsl::EmailSpec) -> lettre::SmtpTransport {
107 use lettre::SmtpTransport;
108 let smtp_server = spec.smtp_server.clone().unwrap_or_else(|| {
109 std::env::var("SMTP_SERVER").unwrap_or_else(|_| "localhost".to_string())
110 });
111 let smtp_port = spec.smtp_port.unwrap_or_else(|| {
112 std::env::var("SMTP_PORT")
113 .ok()
114 .and_then(|p| p.parse().ok())
115 .unwrap_or(25)
116 });
117
118 let mut mailer_builder = SmtpTransport::builder_dangerous(&smtp_server).port(smtp_port);
119
120 if let (Some(user), Some(pass)) = (
121 spec.smtp_username
122 .clone()
123 .or_else(|| std::env::var("SMTP_USERNAME").ok()),
124 spec.smtp_password
125 .clone()
126 .or_else(|| std::env::var("SMTP_PASSWORD").ok()),
127 ) {
128 let credentials = lettre::transport::smtp::authentication::Credentials::new(user, pass);
129 mailer_builder = mailer_builder.credentials(credentials);
130 }
131
132 mailer_builder.build()
133}
134
135#[cfg(all(test, feature = "email"))]
136mod tests {
137 use super::*;
138 use stormchaser_model::dsl::{EmailBackend, EmailSpec};
139
140 #[test]
141 #[cfg(feature = "email")]
142 fn test_generate_approval_links() {
143 let run_id = stormchaser_model::RunId::new_v4();
144 let step_id = stormchaser_model::StepInstanceId::new_v4();
145 let secret = "test-secret";
146 let base_url = "https://paninfracon.net";
147
148 let (approve, reject) = generate_approval_links(run_id, step_id, secret, base_url).unwrap();
149
150 assert!(approve.starts_with(base_url));
151 assert!(reject.starts_with(base_url));
152 assert!(approve.contains("/api/v1/approve-link/"));
153 assert!(reject.contains("/api/v1/approve-link/"));
154 assert_ne!(approve, reject);
155 }
156
157 #[test]
158 #[cfg(feature = "email")]
159 fn test_build_approval_mailer_explicit() {
160 let spec = EmailSpec {
161 from: "sender@paninfracon.net".to_string(),
162 to: vec!["receiver@paninfracon.net".to_string()],
163 cc: None,
164 bcc: None,
165 subject: "Test".to_string(),
166 body: "Hello".to_string(),
167 html: None,
168 backend: Some(EmailBackend::Smtp),
169 smtp_server: Some("smtp.paninfracon.net".to_string()),
170 smtp_port: Some(587),
171 smtp_username: Some("dummy_user".to_string()),
172 smtp_password: Some("dummy_password".to_string()),
173 smtp_use_tls: Some(true),
174 smtp_use_mtls: None,
175 ses_region: None,
176 ses_role_arn: None,
177 ses_configuration_set_name: None,
178 };
179
180 let _mailer = build_approval_mailer(&spec);
181 }
182
183 #[test]
184 #[cfg(feature = "email")]
185 fn test_build_approval_mailer_env_vars() {
186 std::env::set_var("SMTP_SERVER", "env-smtp.paninfracon.net");
187 std::env::set_var("SMTP_PORT", "2525");
188 std::env::set_var("SMTP_USERNAME", "env-user");
189 std::env::set_var("SMTP_PASSWORD", "env-pass");
190
191 let spec = EmailSpec {
192 from: "sender@paninfracon.net".to_string(),
193 to: vec!["receiver@paninfracon.net".to_string()],
194 cc: None,
195 bcc: None,
196 subject: "Test".to_string(),
197 body: "Hello".to_string(),
198 html: None,
199 backend: Some(EmailBackend::Smtp),
200 smtp_server: None,
201 smtp_port: None,
202 smtp_username: None,
203 smtp_password: None,
204 smtp_use_tls: None,
205 smtp_use_mtls: None,
206 ses_region: None,
207 ses_role_arn: None,
208 ses_configuration_set_name: None,
209 };
210
211 let _mailer = build_approval_mailer(&spec);
212
213 std::env::remove_var("SMTP_SERVER");
214 std::env::remove_var("SMTP_PORT");
215 std::env::remove_var("SMTP_USERNAME");
216 std::env::remove_var("SMTP_PASSWORD");
217 }
218}