greentic_runner_host/runner/
adapt_events_email.rs1use 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}