Skip to main content

stormchaser_engine/handler/integrations/
approval.rs

1use anyhow::Result;
2use serde_json::Value;
3use sqlx::PgPool;
4
5#[cfg(feature = "email")]
6#[cfg(feature = "email")]
7use stormchaser_model::workflow;
8
9/// Handle approval notification.
10pub 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        // 1. Generate Tokens
30        let (approve_link, reject_link) =
31            generate_approval_links(run_id, step_id, &secret, &base_url)?;
32
33        // 2. Prepare Context
34        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        // 3. Render Body
52        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        // 4. Build Email
58        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        // 5. Send Email
76        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}