1use std::sync::Arc;
10
11use async_trait::async_trait;
12use car_engine::ToolExecutor;
13use serde_json::{json, Value};
14
15const SEND_TIER: &str = "full_access";
16const MAIL_BUNDLE_ID: &str = "com.apple.mail";
17const MAIL_PERMISSION_FIX: &str =
18 "Open System Settings > Privacy & Security > Automation, allow CAR/CarHost to control Mail, then retry.";
19
20pub(super) trait MailBackend: Send + Sync {
21 fn permission_status(&self) -> Result<String, String>;
22 fn inbox(&self, account_ids: &[String]) -> Result<Value, String>;
23 fn messages(&self, query: &Value) -> Result<Value, String>;
24 fn message_body(&self, message_id: &str) -> Result<Value, String>;
25 fn send(&self, request: &Value) -> Result<Value, String>;
26}
27
28struct MailAppBackend;
29
30impl MailBackend for MailAppBackend {
31 fn permission_status(&self) -> Result<String, String> {
32 let status = car_ffi_common::permissions::status("automation", Some(MAIL_BUNDLE_ID))?;
33 status
34 .get("status")
35 .and_then(Value::as_str)
36 .map(str::to_string)
37 .ok_or_else(|| "Mail Automation permission probe returned no status".to_string())
38 }
39
40 fn inbox(&self, account_ids: &[String]) -> Result<Value, String> {
41 car_ffi_common::integrations::mail_inbox(account_ids)
42 }
43
44 fn messages(&self, query: &Value) -> Result<Value, String> {
45 car_ffi_common::integrations::mail_messages(&query.to_string())
46 }
47
48 fn message_body(&self, message_id: &str) -> Result<Value, String> {
49 car_ffi_common::integrations::mail_message_body(message_id)
50 }
51
52 fn send(&self, request: &Value) -> Result<Value, String> {
53 car_ffi_common::integrations::mail_send(&request.to_string())
54 }
55}
56
57pub struct MailTools {
59 backend: Arc<dyn MailBackend>,
60 macos: bool,
61}
62
63impl MailTools {
64 pub fn new() -> Self {
65 Self {
66 backend: Arc::new(MailAppBackend),
67 macos: cfg!(target_os = "macos"),
68 }
69 }
70
71 #[cfg(test)]
72 pub(super) fn with_backend(backend: Arc<dyn MailBackend>, macos: bool) -> Self {
73 Self { backend, macos }
74 }
75
76 pub fn tool_defs(&self) -> Vec<Value> {
79 if self.macos
80 && self
81 .backend
82 .permission_status()
83 .is_ok_and(|status| status == "granted")
84 {
85 mail_tool_defs()
86 } else {
87 Vec::new()
88 }
89 }
90
91 fn require_permission(&self) -> Result<(), String> {
92 if !self.macos {
93 return Err("local Mail.app tools are available only on macOS".to_string());
94 }
95 let status = self
96 .backend
97 .permission_status()
98 .unwrap_or_else(|_| "unknown".to_string());
99 if status == "granted" {
100 return Ok(());
101 }
102 Err(format!(
103 "Mail Automation access is {status}; local mail tools cannot run. {MAIL_PERMISSION_FIX}"
104 ))
105 }
106
107 fn inbox(&self, params: &Value) -> Result<Value, String> {
108 let account_ids = optional_strings(params, "account_ids")?;
109 self.backend.inbox(&account_ids)
110 }
111
112 fn search(&self, params: &Value) -> Result<Value, String> {
113 let mut query = params.clone();
114 let object = query
115 .as_object_mut()
116 .ok_or_else(|| "mail_search parameters must be an object".to_string())?;
117 let limit = object.get("limit").and_then(Value::as_u64).unwrap_or(50);
118 let cap = car_ffi_common::integrations::MESSAGE_READ_LIMIT_CAP as u64;
119 if limit == 0 || limit > cap {
120 return Err(format!("mail_search limit must be between 1 and {cap}"));
121 }
122 object.insert("include_body".to_string(), Value::Bool(false));
125 self.backend.messages(&query)
126 }
127
128 fn message_body(&self, params: &Value) -> Result<Value, String> {
129 self.backend.message_body(required_string(params, "id")?)
130 }
131
132 fn write_message(&self, params: &Value, draft_only: bool) -> Result<Value, String> {
133 let mut request = params.clone();
134 let object = request
135 .as_object_mut()
136 .ok_or_else(|| "mail write parameters must be an object".to_string())?;
137 object
138 .entry("account_id".to_string())
139 .or_insert_with(|| Value::String(String::new()));
140 object.insert("draft_only".to_string(), Value::Bool(draft_only));
141 let tool = if draft_only {
142 "mail_draft"
143 } else {
144 "mail_send"
145 };
146 let mut result = self.backend.send(&request)?;
147 ensure_message_identifier(&result, tool)?;
148 if result.get("sent").and_then(Value::as_bool) != Some(true) {
153 return Err(format!(
154 "{tool} did not complete: Mail.app reported the message was not {}",
155 if draft_only { "drafted" } else { "sent" }
156 ));
157 }
158 if draft_only {
159 let object = result
160 .as_object_mut()
161 .ok_or_else(|| "mail_draft backend returned a non-object result".to_string())?;
162 object.insert("drafted".to_string(), Value::Bool(true));
163 object.insert("sent".to_string(), Value::Bool(false));
164 }
165 Ok(result)
166 }
167}
168
169impl Default for MailTools {
170 fn default() -> Self {
171 Self::new()
172 }
173}
174
175pub(super) fn mail_tool_defs() -> Vec<Value> {
177 let recipients = || {
178 json!({
179 "type": "array",
180 "items": { "type": "string" },
181 "description": "Email addresses."
182 })
183 };
184 let write_parameters = || {
185 json!({
186 "type": "object",
187 "properties": {
188 "account_id": { "type": "string", "description": "Optional local Mail account id or address. Omit to use Mail.app's default sender." },
189 "to": recipients(),
190 "cc": recipients(),
191 "bcc": recipients(),
192 "subject": { "type": "string" },
193 "body": { "type": "string" }
194 },
195 "required": ["to", "subject", "body"],
196 "additionalProperties": false
197 })
198 };
199
200 vec![
201 json!({
202 "name": "mail_inbox",
203 "description": "Read per-account unread counts and newest subjects from the current Mac user's local Mail.app. This on-device path does not use Parslee or require a Microsoft 365 connection.",
204 "parameters": {
205 "type": "object",
206 "properties": {
207 "account_ids": { "type": "array", "items": { "type": "string" }, "description": "Optional local Mail account ids or addresses; omit for every account." }
208 },
209 "additionalProperties": false
210 }
211 }),
212 json!({
213 "name": "mail_search",
214 "description": "Read newest message rows from one local Mail.app mailbox, optionally limited by account and received time. Results are newest-first, header-only, and include CAR's stable id for mail_message_body plus the RFC 5322 message_id when Mail exposes it. This is local Mail.app; use m365_task for the connected Parslee cloud path. After answering a guided reply check, end with a concrete offer to prepare one reply as an approval-gated Mail.app draft.",
215 "parameters": {
216 "type": "object",
217 "properties": {
218 "account_ids": { "type": "array", "items": { "type": "string" }, "description": "Optional local Mail account ids or addresses; omit for every account." },
219 "mailbox": { "type": "string", "description": "Mailbox name or path. Omit for INBOX." },
220 "since": { "type": "string", "description": "Optional inclusive RFC3339 received-time lower bound." },
221 "limit": { "type": "integer", "minimum": 1, "maximum": 500, "default": 50 }
222 },
223 "additionalProperties": false
224 }
225 }),
226 json!({
227 "name": "mail_message_body",
228 "description": "Read one local Mail.app message body by the stable id returned from mail_search. The backend truncates oversized bodies and reports whether truncation occurred.",
229 "parameters": {
230 "type": "object",
231 "properties": { "id": { "type": "string" } },
232 "required": ["id"],
233 "additionalProperties": false
234 }
235 }),
236 json!({
237 "name": "mail_draft",
238 "description": "Create a draft in the current Mac user's local Mail.app without sending it. Draft creation changes Mail.app and requires chat approval unless the session has full access. Returns the draft message id.",
239 "parameters": write_parameters(),
240 "mutating": true,
241 "tier": SEND_TIER
242 }),
243 json!({
244 "name": "mail_send",
245 "description": "Send a message through the current Mac user's local Mail.app. This sends externally and requires chat approval unless the session has full access. Returns the sent message id.",
246 "parameters": write_parameters(),
247 "mutating": true,
248 "tier": SEND_TIER
249 }),
250 ]
251}
252
253#[async_trait]
254impl ToolExecutor for MailTools {
255 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
256 match tool {
257 "mail_inbox" => {
258 self.require_permission()?;
259 self.inbox(params)
260 }
261 "mail_search" => {
262 self.require_permission()?;
263 self.search(params)
264 }
265 "mail_message_body" => {
266 self.require_permission()?;
267 self.message_body(params)
268 }
269 "mail_draft" => {
270 self.require_permission()?;
271 self.write_message(params, true)
272 }
273 "mail_send" => {
274 self.require_permission()?;
275 self.write_message(params, false)
276 }
277 other => Err(format!("unknown tool: '{other}'")),
278 }
279 }
280}
281
282fn required_string<'a>(params: &'a Value, field: &str) -> Result<&'a str, String> {
283 params
284 .get(field)
285 .and_then(Value::as_str)
286 .map(str::trim)
287 .filter(|value| !value.is_empty())
288 .ok_or_else(|| format!("mail tool requires non-empty `{field}`"))
289}
290
291fn optional_strings(params: &Value, field: &str) -> Result<Vec<String>, String> {
292 match params.get(field) {
293 None => Ok(Vec::new()),
294 Some(Value::Array(values)) => values
295 .iter()
296 .map(|value| {
297 value
298 .as_str()
299 .map(str::trim)
300 .filter(|value| !value.is_empty())
301 .map(str::to_string)
302 .ok_or_else(|| format!("mail `{field}` must contain non-empty strings"))
303 })
304 .collect(),
305 Some(_) => Err(format!("mail `{field}` must be an array of strings")),
306 }
307}
308
309fn ensure_message_identifier(result: &Value, tool: &str) -> Result<(), String> {
310 if result.get("sent").and_then(Value::as_bool) != Some(true) {
311 return Ok(());
312 }
313 if result
314 .get("message_id")
315 .and_then(Value::as_str)
316 .is_some_and(|id| !id.is_empty())
317 {
318 return Ok(());
319 }
320 Err(format!(
321 "{tool} succeeded without the message id required for follow-up evidence"
322 ))
323}
324
325#[cfg(test)]
326mod tests {
327 use std::collections::HashMap;
328 use std::sync::Mutex;
329
330 use car_eventlog::EventKind;
331 use car_ir::{Action, ActionProposal, ActionStatus, ActionType};
332
333 use super::*;
334
335 struct MockMailBackend {
336 status: Result<String, String>,
337 write_completes: bool,
338 calls: Mutex<Vec<(String, Value)>>,
339 }
340
341 impl MockMailBackend {
342 fn granted() -> Arc<Self> {
343 Arc::new(Self {
344 status: Ok("granted".to_string()),
345 write_completes: true,
346 calls: Mutex::new(Vec::new()),
347 })
348 }
349
350 fn write_refused() -> Arc<Self> {
353 Arc::new(Self {
354 status: Ok("granted".to_string()),
355 write_completes: false,
356 calls: Mutex::new(Vec::new()),
357 })
358 }
359
360 fn with_status(status: &str) -> Arc<Self> {
361 Arc::new(Self {
362 status: Ok(status.to_string()),
363 write_completes: true,
364 calls: Mutex::new(Vec::new()),
365 })
366 }
367 }
368
369 impl MailBackend for MockMailBackend {
370 fn permission_status(&self) -> Result<String, String> {
371 self.status.clone()
372 }
373
374 fn inbox(&self, account_ids: &[String]) -> Result<Value, String> {
375 self.calls
376 .lock()
377 .unwrap()
378 .push(("inbox".to_string(), json!({"account_ids": account_ids})));
379 Ok(json!({
380 "available": true,
381 "backend": "mock_mail_app",
382 "summaries": [{"account_id": "work", "unread": 3, "total": 12, "most_recent_subject": "Status"}]
383 }))
384 }
385
386 fn messages(&self, query: &Value) -> Result<Value, String> {
387 self.calls
388 .lock()
389 .unwrap()
390 .push(("search".to_string(), query.clone()));
391 Ok(json!({
392 "available": true,
393 "backend": "mock_mail_app",
394 "messages": [{
395 "id": "mailapp:d29yaw:SU5CT1g:7",
396 "message_id": "<status-7@example.com>",
397 "account_id": "work",
398 "mailbox": "INBOX",
399 "subject": "Status"
400 }]
401 }))
402 }
403
404 fn message_body(&self, message_id: &str) -> Result<Value, String> {
405 self.calls
406 .lock()
407 .unwrap()
408 .push(("body".to_string(), json!({"message_id": message_id})));
409 Ok(json!({
410 "available": true,
411 "backend": "mock_mail_app",
412 "id": message_id,
413 "content_type": "text",
414 "body": "Project is green.",
415 "truncated": false
416 }))
417 }
418
419 fn send(&self, request: &Value) -> Result<Value, String> {
420 self.calls
421 .lock()
422 .unwrap()
423 .push(("send".to_string(), request.clone()));
424 let id = if request["draft_only"] == true {
425 "draft-17"
426 } else {
427 "sent-18"
428 };
429 if !self.write_completes {
430 return Ok(json!({
431 "available": true,
432 "backend": "mock_mail_app",
433 "sent": false
434 }));
435 }
436 Ok(json!({
437 "available": true,
438 "backend": "mock_mail_app",
439 "sent": true,
440 "message_id": id
441 }))
442 }
443 }
444
445 #[test]
446 fn schemas_assign_mail_writes_to_the_approval_tier() {
447 let defs = mail_tool_defs();
448 let names: Vec<&str> = defs.iter().filter_map(|def| def["name"].as_str()).collect();
449 assert_eq!(
450 names,
451 [
452 "mail_inbox",
453 "mail_search",
454 "mail_message_body",
455 "mail_draft",
456 "mail_send"
457 ]
458 );
459 assert!(defs[..3].iter().all(|def| def.get("tier").is_none()));
460 assert_eq!(defs[3]["mutating"], true);
461 assert_eq!(defs[4]["mutating"], true);
462 assert_eq!(defs[3]["tier"], SEND_TIER);
463 assert_eq!(defs[4]["tier"], SEND_TIER);
464 assert!(defs[1]["description"]
465 .as_str()
466 .is_some_and(|description| description.contains("offer to prepare one reply")));
467 assert_eq!(
468 defs[1]["parameters"]["properties"]["limit"]["maximum"],
469 car_ffi_common::integrations::MESSAGE_READ_LIMIT_CAP
470 );
471 }
472
473 #[test]
474 fn mail_writes_gate_until_the_session_has_full_access() {
475 let defs = mail_tool_defs();
476 assert_eq!(
477 super::super::tier_gated_tool_names(
478 &defs,
479 car_policy::permission::PermissionTier::ReadOnly,
480 ),
481 ["mail_draft", "mail_send"]
482 );
483 assert!(super::super::tier_gated_tool_names(
484 &defs,
485 car_policy::permission::PermissionTier::FullAccess,
486 )
487 .is_empty());
488 }
489
490 #[test]
491 fn advertisement_requires_macos_and_granted_mail_automation() {
492 let granted: Arc<dyn MailBackend> = MockMailBackend::granted();
493 assert_eq!(MailTools::with_backend(granted, true).tool_defs().len(), 5);
494
495 let denied: Arc<dyn MailBackend> = MockMailBackend::with_status("denied");
496 assert!(MailTools::with_backend(denied, true).tool_defs().is_empty());
497
498 let off_platform: Arc<dyn MailBackend> = MockMailBackend::granted();
499 assert!(MailTools::with_backend(off_platform, false)
500 .tool_defs()
501 .is_empty());
502 }
503
504 #[tokio::test]
505 async fn stale_permission_returns_actionable_mail_remediation() {
506 let backend: Arc<dyn MailBackend> = MockMailBackend::with_status("denied");
507 let error = MailTools::with_backend(backend, true)
508 .execute("mail_inbox", &json!({}))
509 .await
510 .unwrap_err();
511 assert!(
512 error.contains("Mail Automation access is denied"),
513 "{error}"
514 );
515 assert!(error.contains("System Settings"), "{error}");
516 assert!(error.contains("Automation"), "{error}");
517 assert!(error.contains("Mail"), "{error}");
518 }
519
520 #[tokio::test]
521 async fn reads_are_bounded_and_preserve_both_message_identifiers() {
522 let backend = MockMailBackend::granted();
523 let tools = MailTools::with_backend(backend.clone(), true);
524 let inbox = tools
525 .execute("mail_inbox", &json!({"account_ids": ["work"]}))
526 .await
527 .unwrap();
528 assert_eq!(inbox["summaries"][0]["unread"], 3);
529
530 let rows = tools
531 .execute(
532 "mail_search",
533 &json!({"mailbox": "INBOX", "since": "2026-09-17T00:00:00Z", "limit": 20}),
534 )
535 .await
536 .unwrap();
537 assert_eq!(rows["messages"][0]["id"], "mailapp:d29yaw:SU5CT1g:7");
538 assert_eq!(rows["messages"][0]["message_id"], "<status-7@example.com>");
539 assert_eq!(backend.calls.lock().unwrap()[1].1["include_body"], false);
540
541 let error = tools
542 .execute("mail_search", &json!({"limit": 501}))
543 .await
544 .unwrap_err();
545 assert!(error.contains("between 1 and 500"), "{error}");
546 assert_eq!(
547 backend
548 .calls
549 .lock()
550 .unwrap()
551 .iter()
552 .filter(|(name, _)| name == "search")
553 .count(),
554 1,
555 "an oversized read must not reach the backend"
556 );
557 }
558
559 #[tokio::test]
560 async fn body_uses_the_stable_car_message_id() {
561 let backend = MockMailBackend::granted();
562 let result = MailTools::with_backend(backend.clone(), true)
563 .execute(
564 "mail_message_body",
565 &json!({"id": "mailapp:d29yaw:SU5CT1g:7"}),
566 )
567 .await
568 .unwrap();
569 assert_eq!(result["id"], "mailapp:d29yaw:SU5CT1g:7");
570 assert_eq!(result["body"], "Project is green.");
571 assert_eq!(backend.calls.lock().unwrap()[0].0, "body");
572 }
573
574 #[tokio::test]
575 async fn draft_never_sends_while_send_returns_its_message_id() {
576 let backend = MockMailBackend::granted();
577 let tools = MailTools::with_backend(backend.clone(), true);
578 let params = json!({
579 "account_id": "work",
580 "to": ["person@example.com"],
581 "subject": "Status",
582 "body": "Project is green."
583 });
584
585 let draft = tools.execute("mail_draft", ¶ms).await.unwrap();
586 assert_eq!(draft["drafted"], true);
587 assert_eq!(draft["sent"], false);
588 assert_eq!(draft["message_id"], "draft-17");
589
590 let sent = tools.execute("mail_send", ¶ms).await.unwrap();
591 assert_eq!(sent["sent"], true);
592 assert_eq!(sent["message_id"], "sent-18");
593
594 let calls = backend.calls.lock().unwrap();
595 assert_eq!(calls[0].1["draft_only"], true);
596 assert_eq!(calls[1].1["draft_only"], false);
597 }
598
599 #[tokio::test]
600 async fn a_send_mail_refused_is_not_a_completed_action() {
601 let backend = MockMailBackend::write_refused();
602 let tools = MailTools::with_backend(backend.clone(), true);
603 let params = json!({
604 "account_id": "work",
605 "to": ["person@example.com"],
606 "subject": "Status",
607 "body": "Project is green."
608 });
609
610 let error = tools
611 .execute("mail_send", ¶ms)
612 .await
613 .expect_err("Mail reporting the message was not sent is a failed call");
614 assert!(
615 error.contains("mail_send") && error.contains("not sent"),
616 "the error names the tool and what did not happen: {error}"
617 );
618 assert_eq!(backend.calls.lock().unwrap().len(), 1);
619 }
620
621 #[tokio::test]
622 async fn a_draft_mail_refused_is_not_a_completed_action() {
623 let backend = MockMailBackend::write_refused();
624 let tools = MailTools::with_backend(backend.clone(), true);
625 let params = json!({
626 "account_id": "work",
627 "to": ["person@example.com"],
628 "subject": "Status",
629 "body": "Project is green."
630 });
631
632 let error = tools
633 .execute("mail_draft", ¶ms)
634 .await
635 .expect_err("Mail reporting the draft was not written is a failed call");
636 assert!(
637 error.contains("mail_draft") && error.contains("not drafted"),
638 "the error names the tool and what did not happen: {error}"
639 );
640 }
641
642 #[tokio::test]
643 async fn runtime_policy_and_event_log_wrap_mail_dispatch() {
644 let backend = MockMailBackend::granted();
645 let tools = Arc::new(MailTools::with_backend(backend.clone(), true));
646 let executor: Arc<dyn ToolExecutor> = tools;
647 let runtime = car_engine::Runtime::new().with_executor(executor);
648 let def = mail_tool_defs()
649 .into_iter()
650 .find(|def| def["name"] == "mail_send")
651 .unwrap();
652 runtime
653 .register_tool_entry(
654 car_engine::ToolEntry::new(super::super::schema_from_def(&def))
655 .with_side_effects(true),
656 )
657 .await;
658 runtime
659 .set_capabilities(car_engine::CapabilitySet::new().deny_tool("mail_send"))
660 .await;
661
662 let mut action = Action::new(ActionType::ToolCall);
663 action.id = "denied-mail-send".to_string();
664 action.tool = Some("mail_send".to_string());
665 action.parameters = serde_json::from_value(json!({
666 "to": ["person@example.com"],
667 "subject": "Status",
668 "body": "Project is green."
669 }))
670 .unwrap();
671 let proposal = ActionProposal {
672 id: "mail-policy-test".to_string(),
673 source: "test".to_string(),
674 actions: vec![action],
675 timestamp: chrono::Utc::now(),
676 context: HashMap::new(),
677 };
678 let result = runtime.execute(&proposal).await;
679 assert_eq!(result.results[0].status, ActionStatus::Rejected);
680 assert!(backend.calls.lock().unwrap().is_empty());
681 let log = runtime.log.lock().await;
682 let rejection = log
683 .events()
684 .iter()
685 .find(|event| {
686 event.kind == EventKind::ActionRejected
687 && event.action_id.as_deref() == Some("denied-mail-send")
688 })
689 .expect("runtime policy rejection must be journaled");
690 assert_eq!(rejection.data["stage"], "capability");
691 assert_eq!(rejection.data["attempted"], false);
692 drop(log);
693
694 runtime
695 .set_capabilities(car_engine::CapabilitySet::new())
696 .await;
697 let mut allowed = Action::new(ActionType::ToolCall);
698 allowed.id = "allowed-mail-send".to_string();
699 allowed.tool = Some("mail_send".to_string());
700 allowed.parameters = serde_json::from_value(json!({
701 "to": ["person@example.com"],
702 "subject": "Status",
703 "body": "Project is green."
704 }))
705 .unwrap();
706 let allowed_result = runtime
707 .execute(&ActionProposal {
708 id: "mail-event-test".to_string(),
709 source: "test".to_string(),
710 actions: vec![allowed],
711 timestamp: chrono::Utc::now(),
712 context: HashMap::new(),
713 })
714 .await;
715 assert!(allowed_result.all_succeeded());
716 assert_eq!(backend.calls.lock().unwrap().len(), 1);
717 assert!(runtime.log.lock().await.events().iter().any(|event| {
718 event.kind == EventKind::ActionSucceeded
719 && event.action_id.as_deref() == Some("allowed-mail-send")
720 }));
721 }
722
723 #[tokio::test]
724 async fn unknown_tool_falls_through() {
725 let backend: Arc<dyn MailBackend> = MockMailBackend::granted();
726 let error = MailTools::with_backend(backend, true)
727 .execute("calendar_events", &json!({}))
728 .await
729 .unwrap_err();
730 assert!(error.starts_with("unknown tool"), "{error}");
731 }
732}