Skip to main content

car_server_core/assistant/
mail_tools.rs

1//! Local macOS Mail.app tools for the built-in assistant.
2//!
3//! Reads, drafts, and sends use the existing `car-integrations` Mail.app
4//! backend. They do not require a Parslee session or connected Microsoft 365
5//! account. Runtime advertisement is restricted to macOS when the
6//! non-prompting Automation probe for `com.apple.mail` reports ready; stale
7//! calls re-check that permission and return actionable remediation.
8
9use 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
57/// Model-facing access to the current Mac user's local Mail.app data.
58pub 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    /// Advertise only when Mail.app Automation is already granted. The probe
77    /// never prompts or launches Mail.
78    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        // Body retrieval has its own bounded tool. Keeping rows header-only
123        // prevents one search from pulling up to 500 large message bodies.
124        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        // Mail.app answers a refused or failed write with `sent: false`. Handing
149        // that back as a successful call records a completed action for
150        // something the mailbox never did, so the write fails here instead and
151        // the turn's history keeps matching what Mail actually holds.
152        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
175/// All local-Mail schemas, including those unavailable on this host.
176pub(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        /// Access is granted and Mail answers, but the write does not happen —
351        /// what Mail.app reports when it refuses or fails a send or a draft.
352        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", &params).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", &params).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", &params)
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", &params)
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}