Skip to main content

greentic_runner_host/runner/
adapt_events_email.rs

1use anyhow::{Context, Result, anyhow, bail};
2use base64::Engine as _;
3use greentic_types::TenantCtx;
4use reqwest::Url;
5use serde::Deserialize;
6use serde_json::{Value, json};
7
8use crate::oauth::{OAuthBrokerConfig, ResourceTokenRequest, build_resource_token_request};
9
10#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "lowercase")]
12pub enum EmailProviderKind {
13    MsGraph,
14    Gmail,
15}
16
17#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
18pub struct EmailOauthHint {
19    pub provider_id: String,
20    pub flow: String,
21    #[serde(default)]
22    pub scopes: Vec<String>,
23}
24
25#[derive(Debug, Clone, Deserialize, PartialEq)]
26pub struct EmailSendRequest {
27    pub provider: EmailProviderKind,
28    pub payload: Value,
29    pub oauth: Option<EmailOauthHint>,
30    #[serde(default)]
31    pub secret_events: Vec<Value>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct EmailHttpExecution {
36    pub method: &'static str,
37    pub url: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct EmailExecutionPlan {
42    pub token_request: ResourceTokenRequest,
43    pub http: EmailHttpExecution,
44}
45
46pub fn parse_email_send_request(value: &Value) -> Result<EmailSendRequest> {
47    serde_json::from_value(value.clone()).context("invalid email send request payload")
48}
49
50pub fn build_email_http_execution(request: &EmailSendRequest) -> Result<EmailHttpExecution> {
51    match request.provider {
52        EmailProviderKind::MsGraph => {
53            let sender = request
54                .payload
55                .get("message")
56                .and_then(|m| m.get("from"))
57                .and_then(|from| from.get("emailAddress"))
58                .and_then(|addr| addr.get("address"))
59                .and_then(Value::as_str)
60                .filter(|value| !value.trim().is_empty())
61                .ok_or_else(|| {
62                    anyhow!(
63                        "msgraph email host execution requires message.from.emailAddress.address"
64                    )
65                })?;
66            if sender.contains(['/', '?', '#']) {
67                bail!(
68                    "msgraph sender address must not contain '/', '?' or '#': {}",
69                    sender
70                );
71            }
72            Ok(EmailHttpExecution {
73                method: "POST",
74                url: format!("https://graph.microsoft.com/v1.0/users/{sender}/sendMail"),
75            })
76        }
77        EmailProviderKind::Gmail => Ok(EmailHttpExecution {
78            method: "POST",
79            url: "https://gmail.googleapis.com/gmail/v1/users/me/messages/send".into(),
80        }),
81    }
82}
83
84pub fn required_oauth_hint(request: &EmailSendRequest) -> Result<&EmailOauthHint> {
85    let hint = request
86        .oauth
87        .as_ref()
88        .ok_or_else(|| anyhow!("email send request missing oauth hint"))?;
89    match request.provider {
90        EmailProviderKind::MsGraph => {
91            if hint.provider_id != "msgraph-email" {
92                bail!("msgraph email request must use provider_id `msgraph-email`");
93            }
94        }
95        EmailProviderKind::Gmail => {
96            if hint.provider_id != "gmail-email" {
97                bail!("gmail email request must use provider_id `gmail-email`");
98            }
99        }
100    }
101    Ok(hint)
102}
103
104pub fn build_email_execution_plan(
105    config: &OAuthBrokerConfig,
106    tenant: &TenantCtx,
107    request: &EmailSendRequest,
108) -> Result<EmailExecutionPlan> {
109    let hint = required_oauth_hint(request)?;
110    let token_request =
111        build_resource_token_request(config, tenant, &hint.provider_id, &hint.scopes)?;
112    let http = build_email_http_execution(request)?;
113    Ok(EmailExecutionPlan {
114        token_request,
115        http,
116    })
117}
118
119pub fn build_email_http_payload(request: &EmailSendRequest) -> Result<Value> {
120    match request.provider {
121        EmailProviderKind::MsGraph => Ok(request.payload.clone()),
122        EmailProviderKind::Gmail => {
123            let message = request
124                .payload
125                .get("message")
126                .and_then(Value::as_object)
127                .ok_or_else(|| anyhow!("gmail email request missing `message` object"))?;
128            let subject = message
129                .get("subject")
130                .and_then(Value::as_str)
131                .ok_or_else(|| anyhow!("gmail email request missing `message.subject`"))?;
132            let body = message
133                .get("body")
134                .and_then(Value::as_str)
135                .ok_or_else(|| anyhow!("gmail email request missing `message.body`"))?;
136            let to = string_list(message.get("to"), "message.to")?;
137            let cc = optional_string_list(message.get("cc"));
138            let bcc = optional_string_list(message.get("bcc"));
139            let from = message.get("from").and_then(Value::as_str);
140            let raw = build_gmail_raw_message(from, &to, &cc, &bcc, subject, body);
141            Ok(json!({
142                "raw": base64::engine::general_purpose::STANDARD_NO_PAD.encode(raw.as_bytes())
143            }))
144        }
145    }
146}
147
148pub async fn execute_email_request(
149    client: &reqwest::Client,
150    access_token: &str,
151    request: &EmailSendRequest,
152) -> Result<()> {
153    let plan = build_email_http_execution(request)?;
154    let url = Url::parse(&plan.url).context("invalid email provider URL")?;
155    if url.scheme() != "https" {
156        bail!(
157            "email provider URL must use https, got scheme `{}`",
158            url.scheme()
159        );
160    }
161    if !url.username().is_empty() || url.password().is_some() {
162        bail!("email provider URL must not include URL credentials");
163    }
164    if url.query().is_some() || url.fragment().is_some() {
165        bail!("email provider URL must not include query or fragment components");
166    }
167    let payload = build_email_http_payload(request)?;
168    client
169        .post(url)
170        .bearer_auth(access_token)
171        .json(&payload)
172        .send()
173        .await?
174        .error_for_status()?;
175    Ok(())
176}
177
178fn string_list(value: Option<&Value>, field: &str) -> Result<Vec<String>> {
179    value
180        .and_then(Value::as_array)
181        .map(|arr| {
182            arr.iter()
183                .filter_map(|v| v.as_str().map(ToOwned::to_owned))
184                .collect::<Vec<_>>()
185        })
186        .filter(|items| !items.is_empty())
187        .ok_or_else(|| anyhow!("gmail email request missing `{field}`"))
188}
189
190fn optional_string_list(value: Option<&Value>) -> Vec<String> {
191    value
192        .and_then(Value::as_array)
193        .map(|arr| {
194            arr.iter()
195                .filter_map(|v| v.as_str().map(ToOwned::to_owned))
196                .collect::<Vec<_>>()
197        })
198        .unwrap_or_default()
199}
200
201fn build_gmail_raw_message(
202    from: Option<&str>,
203    to: &[String],
204    cc: &[String],
205    bcc: &[String],
206    subject: &str,
207    body: &str,
208) -> String {
209    let mut lines = Vec::new();
210    if let Some(from) = from.filter(|value| !value.trim().is_empty()) {
211        lines.push(format!("From: {from}"));
212    }
213    lines.push(format!("To: {}", to.join(", ")));
214    if !cc.is_empty() {
215        lines.push(format!("Cc: {}", cc.join(", ")));
216    }
217    if !bcc.is_empty() {
218        lines.push(format!("Bcc: {}", bcc.join(", ")));
219    }
220    lines.push(format!("Subject: {subject}"));
221    lines.push("Content-Type: text/plain; charset=utf-8".into());
222    lines.push(String::new());
223    lines.push(body.to_string());
224    lines.join("\r\n")
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::oauth::OAuthBrokerConfig;
231    use greentic_types::{EnvId, TeamId, TenantId};
232    use serde_json::json;
233    use std::str::FromStr;
234
235    fn sample_tenant() -> TenantCtx {
236        TenantCtx::new(
237            EnvId::from_str("dev").unwrap(),
238            TenantId::from_str("acme").unwrap(),
239        )
240        .with_team(Some(TeamId::from_str("core").unwrap()))
241    }
242
243    #[test]
244    fn parses_msgraph_request_and_builds_execution_target() {
245        let value = json!({
246            "provider": "msgraph",
247            "payload": {
248                "message": {
249                    "subject": "Hello",
250                    "body": { "contentType": "HTML", "content": "<p>Hi</p>" },
251                    "toRecipients": [{ "emailAddress": { "address": "to@example.com" } }],
252                    "from": { "emailAddress": { "address": "sender@example.com" } }
253                },
254                "saveToSentItems": false
255            },
256            "oauth": {
257                "provider_id": "msgraph-email",
258                "flow": "client_credentials",
259                "scopes": ["https://graph.microsoft.com/.default"]
260            },
261            "secret_events": []
262        });
263
264        let request = parse_email_send_request(&value).expect("parse request");
265        let hint = required_oauth_hint(&request).expect("oauth hint");
266        let execution = build_email_http_execution(&request).expect("execution");
267
268        assert_eq!(hint.provider_id, "msgraph-email");
269        assert_eq!(execution.method, "POST");
270        assert_eq!(
271            execution.url,
272            "https://graph.microsoft.com/v1.0/users/sender@example.com/sendMail"
273        );
274    }
275
276    #[test]
277    fn gmail_request_uses_me_send_endpoint() {
278        let value = json!({
279            "provider": "gmail",
280            "payload": {
281                "message": {
282                    "subject": "Hello",
283                    "body": "Hi",
284                    "to": ["to@example.com"]
285                }
286            },
287            "oauth": {
288                "provider_id": "gmail-email",
289                "flow": "refresh_token",
290                "scopes": ["https://www.googleapis.com/auth/gmail.send"]
291            },
292            "secret_events": []
293        });
294
295        let request = parse_email_send_request(&value).expect("parse request");
296        let hint = required_oauth_hint(&request).expect("oauth hint");
297        let execution = build_email_http_execution(&request).expect("execution");
298
299        assert_eq!(hint.provider_id, "gmail-email");
300        assert_eq!(execution.method, "POST");
301        assert_eq!(
302            execution.url,
303            "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
304        );
305    }
306
307    #[test]
308    fn builds_msgraph_execution_plan() {
309        let value = json!({
310            "provider": "msgraph",
311            "payload": {
312                "message": {
313                    "subject": "Hello",
314                    "body": { "contentType": "HTML", "content": "<p>Hi</p>" },
315                    "toRecipients": [{ "emailAddress": { "address": "to@example.com" } }],
316                    "from": { "emailAddress": { "address": "sender@example.com" } }
317                },
318                "saveToSentItems": false
319            },
320            "oauth": {
321                "provider_id": "msgraph-email",
322                "flow": "client_credentials",
323                "scopes": ["https://graph.microsoft.com/.default"]
324            },
325            "secret_events": []
326        });
327
328        let request = parse_email_send_request(&value).expect("parse request");
329        let cfg = OAuthBrokerConfig::new("https://oauth.example", "nats://localhost:4222");
330        let tenant = sample_tenant();
331        let plan = build_email_execution_plan(&cfg, &tenant, &request).expect("plan");
332
333        assert_eq!(plan.token_request.http_base_url, "https://oauth.example");
334        assert_eq!(plan.token_request.resource_id, "msgraph-email");
335        assert_eq!(
336            plan.token_request.scopes,
337            vec!["https://graph.microsoft.com/.default".to_string()]
338        );
339        assert_eq!(plan.http.method, "POST");
340        assert_eq!(
341            plan.http.url,
342            "https://graph.microsoft.com/v1.0/users/sender@example.com/sendMail"
343        );
344    }
345
346    #[test]
347    fn msgraph_payload_is_forwarded_as_is() {
348        let value = json!({
349            "provider": "msgraph",
350            "payload": {
351                "message": {
352                    "subject": "Hello",
353                    "body": { "contentType": "HTML", "content": "<p>Hi</p>" },
354                    "toRecipients": [{ "emailAddress": { "address": "to@example.com" } }],
355                    "from": { "emailAddress": { "address": "sender@example.com" } }
356                },
357                "saveToSentItems": false
358            },
359            "oauth": {
360                "provider_id": "msgraph-email",
361                "flow": "client_credentials",
362                "scopes": ["https://graph.microsoft.com/.default"]
363            },
364            "secret_events": []
365        });
366        let request = parse_email_send_request(&value).expect("parse request");
367        let payload = build_email_http_payload(&request).expect("payload");
368        assert_eq!(payload, request.payload);
369    }
370
371    #[test]
372    fn gmail_payload_is_encoded_as_raw_message() {
373        let value = json!({
374            "provider": "gmail",
375            "payload": {
376                "message": {
377                    "subject": "Hello",
378                    "body": "Hi there",
379                    "to": ["to@example.com"],
380                    "cc": ["cc@example.com"],
381                    "bcc": ["bcc@example.com"],
382                    "from": "sender@example.com"
383                }
384            },
385            "oauth": {
386                "provider_id": "gmail-email",
387                "flow": "refresh_token",
388                "scopes": ["https://www.googleapis.com/auth/gmail.send"]
389            },
390            "secret_events": []
391        });
392
393        let request = parse_email_send_request(&value).expect("parse request");
394        let payload = build_email_http_payload(&request).expect("payload");
395        let raw = payload
396            .get("raw")
397            .and_then(Value::as_str)
398            .expect("raw field");
399        let decoded = base64::engine::general_purpose::STANDARD_NO_PAD
400            .decode(raw.as_bytes())
401            .expect("valid base64");
402        let decoded = String::from_utf8(decoded).expect("utf8");
403
404        assert!(decoded.contains("From: sender@example.com"));
405        assert!(decoded.contains("To: to@example.com"));
406        assert!(decoded.contains("Cc: cc@example.com"));
407        assert!(decoded.contains("Bcc: bcc@example.com"));
408        assert!(decoded.contains("Subject: Hello"));
409        assert!(decoded.ends_with("Hi there"));
410    }
411
412    #[test]
413    fn msgraph_requires_sender_identity() {
414        let value = json!({
415            "provider": "msgraph",
416            "payload": {
417                "message": {
418                    "subject": "Hello"
419                }
420            },
421            "oauth": {
422                "provider_id": "msgraph-email",
423                "flow": "client_credentials",
424                "scopes": ["https://graph.microsoft.com/.default"]
425            }
426        });
427
428        let request = parse_email_send_request(&value).expect("parse request");
429        let err = build_email_http_execution(&request).expect_err("missing sender should fail");
430        assert!(
431            err.to_string()
432                .contains("message.from.emailAddress.address")
433        );
434    }
435
436    #[test]
437    fn msgraph_rejects_sender_with_url_delimiters() {
438        let value = json!({
439            "provider": "msgraph",
440            "payload": {
441                "message": {
442                    "subject": "Hello",
443                    "from": { "emailAddress": { "address": "sender@example.com?debug=1" } }
444                }
445            },
446            "oauth": {
447                "provider_id": "msgraph-email",
448                "flow": "client_credentials",
449                "scopes": ["https://graph.microsoft.com/.default"]
450            }
451        });
452
453        let request = parse_email_send_request(&value).expect("parse request");
454        let err = build_email_http_execution(&request).expect_err("invalid sender should fail");
455        assert!(err.to_string().contains("must not contain '/', '?' or '#'"));
456    }
457}