use std::sync::Arc;
use async_trait::async_trait;
use car_engine::ToolExecutor;
use serde_json::{json, Value};
const SEND_TIER: &str = "full_access";
const MAIL_BUNDLE_ID: &str = "com.apple.mail";
const MAIL_PERMISSION_FIX: &str =
"Open System Settings > Privacy & Security > Automation, allow CAR/CarHost to control Mail, then retry.";
pub(super) trait MailBackend: Send + Sync {
fn permission_status(&self) -> Result<String, String>;
fn inbox(&self, account_ids: &[String]) -> Result<Value, String>;
fn messages(&self, query: &Value) -> Result<Value, String>;
fn message_body(&self, message_id: &str) -> Result<Value, String>;
fn send(&self, request: &Value) -> Result<Value, String>;
}
struct MailAppBackend;
impl MailBackend for MailAppBackend {
fn permission_status(&self) -> Result<String, String> {
let status = car_ffi_common::permissions::status("automation", Some(MAIL_BUNDLE_ID))?;
status
.get("status")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| "Mail Automation permission probe returned no status".to_string())
}
fn inbox(&self, account_ids: &[String]) -> Result<Value, String> {
car_ffi_common::integrations::mail_inbox(account_ids)
}
fn messages(&self, query: &Value) -> Result<Value, String> {
car_ffi_common::integrations::mail_messages(&query.to_string())
}
fn message_body(&self, message_id: &str) -> Result<Value, String> {
car_ffi_common::integrations::mail_message_body(message_id)
}
fn send(&self, request: &Value) -> Result<Value, String> {
car_ffi_common::integrations::mail_send(&request.to_string())
}
}
pub struct MailTools {
backend: Arc<dyn MailBackend>,
macos: bool,
}
impl MailTools {
pub fn new() -> Self {
Self {
backend: Arc::new(MailAppBackend),
macos: cfg!(target_os = "macos"),
}
}
#[cfg(test)]
pub(super) fn with_backend(backend: Arc<dyn MailBackend>, macos: bool) -> Self {
Self { backend, macos }
}
pub fn tool_defs(&self) -> Vec<Value> {
if self.macos
&& self
.backend
.permission_status()
.is_ok_and(|status| status == "granted")
{
mail_tool_defs()
} else {
Vec::new()
}
}
fn require_permission(&self) -> Result<(), String> {
if !self.macos {
return Err("local Mail.app tools are available only on macOS".to_string());
}
let status = self
.backend
.permission_status()
.unwrap_or_else(|_| "unknown".to_string());
if status == "granted" {
return Ok(());
}
Err(format!(
"Mail Automation access is {status}; local mail tools cannot run. {MAIL_PERMISSION_FIX}"
))
}
fn inbox(&self, params: &Value) -> Result<Value, String> {
let account_ids = optional_strings(params, "account_ids")?;
self.backend.inbox(&account_ids)
}
fn search(&self, params: &Value) -> Result<Value, String> {
let mut query = params.clone();
let object = query
.as_object_mut()
.ok_or_else(|| "mail_search parameters must be an object".to_string())?;
let limit = object.get("limit").and_then(Value::as_u64).unwrap_or(50);
let cap = car_ffi_common::integrations::MESSAGE_READ_LIMIT_CAP as u64;
if limit == 0 || limit > cap {
return Err(format!("mail_search limit must be between 1 and {cap}"));
}
object.insert("include_body".to_string(), Value::Bool(false));
self.backend.messages(&query)
}
fn message_body(&self, params: &Value) -> Result<Value, String> {
self.backend.message_body(required_string(params, "id")?)
}
fn write_message(&self, params: &Value, draft_only: bool) -> Result<Value, String> {
let mut request = params.clone();
let object = request
.as_object_mut()
.ok_or_else(|| "mail write parameters must be an object".to_string())?;
object
.entry("account_id".to_string())
.or_insert_with(|| Value::String(String::new()));
object.insert("draft_only".to_string(), Value::Bool(draft_only));
let tool = if draft_only {
"mail_draft"
} else {
"mail_send"
};
let mut result = self.backend.send(&request)?;
ensure_message_identifier(&result, tool)?;
if result.get("sent").and_then(Value::as_bool) != Some(true) {
return Err(format!(
"{tool} did not complete: Mail.app reported the message was not {}",
if draft_only { "drafted" } else { "sent" }
));
}
if draft_only {
let object = result
.as_object_mut()
.ok_or_else(|| "mail_draft backend returned a non-object result".to_string())?;
object.insert("drafted".to_string(), Value::Bool(true));
object.insert("sent".to_string(), Value::Bool(false));
}
Ok(result)
}
}
impl Default for MailTools {
fn default() -> Self {
Self::new()
}
}
pub(super) fn mail_tool_defs() -> Vec<Value> {
let recipients = || {
json!({
"type": "array",
"items": { "type": "string" },
"description": "Email addresses."
})
};
let write_parameters = || {
json!({
"type": "object",
"properties": {
"account_id": { "type": "string", "description": "Optional local Mail account id or address. Omit to use Mail.app's default sender." },
"to": recipients(),
"cc": recipients(),
"bcc": recipients(),
"subject": { "type": "string" },
"body": { "type": "string" }
},
"required": ["to", "subject", "body"],
"additionalProperties": false
})
};
vec![
json!({
"name": "mail_inbox",
"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.",
"parameters": {
"type": "object",
"properties": {
"account_ids": { "type": "array", "items": { "type": "string" }, "description": "Optional local Mail account ids or addresses; omit for every account." }
},
"additionalProperties": false
}
}),
json!({
"name": "mail_search",
"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.",
"parameters": {
"type": "object",
"properties": {
"account_ids": { "type": "array", "items": { "type": "string" }, "description": "Optional local Mail account ids or addresses; omit for every account." },
"mailbox": { "type": "string", "description": "Mailbox name or path. Omit for INBOX." },
"since": { "type": "string", "description": "Optional inclusive RFC3339 received-time lower bound." },
"limit": { "type": "integer", "minimum": 1, "maximum": 500, "default": 50 }
},
"additionalProperties": false
}
}),
json!({
"name": "mail_message_body",
"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.",
"parameters": {
"type": "object",
"properties": { "id": { "type": "string" } },
"required": ["id"],
"additionalProperties": false
}
}),
json!({
"name": "mail_draft",
"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.",
"parameters": write_parameters(),
"mutating": true,
"tier": SEND_TIER
}),
json!({
"name": "mail_send",
"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.",
"parameters": write_parameters(),
"mutating": true,
"tier": SEND_TIER
}),
]
}
#[async_trait]
impl ToolExecutor for MailTools {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"mail_inbox" => {
self.require_permission()?;
self.inbox(params)
}
"mail_search" => {
self.require_permission()?;
self.search(params)
}
"mail_message_body" => {
self.require_permission()?;
self.message_body(params)
}
"mail_draft" => {
self.require_permission()?;
self.write_message(params, true)
}
"mail_send" => {
self.require_permission()?;
self.write_message(params, false)
}
other => Err(format!("unknown tool: '{other}'")),
}
}
}
fn required_string<'a>(params: &'a Value, field: &str) -> Result<&'a str, String> {
params
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("mail tool requires non-empty `{field}`"))
}
fn optional_strings(params: &Value, field: &str) -> Result<Vec<String>, String> {
match params.get(field) {
None => Ok(Vec::new()),
Some(Value::Array(values)) => values
.iter()
.map(|value| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| format!("mail `{field}` must contain non-empty strings"))
})
.collect(),
Some(_) => Err(format!("mail `{field}` must be an array of strings")),
}
}
fn ensure_message_identifier(result: &Value, tool: &str) -> Result<(), String> {
if result.get("sent").and_then(Value::as_bool) != Some(true) {
return Ok(());
}
if result
.get("message_id")
.and_then(Value::as_str)
.is_some_and(|id| !id.is_empty())
{
return Ok(());
}
Err(format!(
"{tool} succeeded without the message id required for follow-up evidence"
))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Mutex;
use car_eventlog::EventKind;
use car_ir::{Action, ActionProposal, ActionStatus, ActionType};
use super::*;
struct MockMailBackend {
status: Result<String, String>,
write_completes: bool,
calls: Mutex<Vec<(String, Value)>>,
}
impl MockMailBackend {
fn granted() -> Arc<Self> {
Arc::new(Self {
status: Ok("granted".to_string()),
write_completes: true,
calls: Mutex::new(Vec::new()),
})
}
fn write_refused() -> Arc<Self> {
Arc::new(Self {
status: Ok("granted".to_string()),
write_completes: false,
calls: Mutex::new(Vec::new()),
})
}
fn with_status(status: &str) -> Arc<Self> {
Arc::new(Self {
status: Ok(status.to_string()),
write_completes: true,
calls: Mutex::new(Vec::new()),
})
}
}
impl MailBackend for MockMailBackend {
fn permission_status(&self) -> Result<String, String> {
self.status.clone()
}
fn inbox(&self, account_ids: &[String]) -> Result<Value, String> {
self.calls
.lock()
.unwrap()
.push(("inbox".to_string(), json!({"account_ids": account_ids})));
Ok(json!({
"available": true,
"backend": "mock_mail_app",
"summaries": [{"account_id": "work", "unread": 3, "total": 12, "most_recent_subject": "Status"}]
}))
}
fn messages(&self, query: &Value) -> Result<Value, String> {
self.calls
.lock()
.unwrap()
.push(("search".to_string(), query.clone()));
Ok(json!({
"available": true,
"backend": "mock_mail_app",
"messages": [{
"id": "mailapp:d29yaw:SU5CT1g:7",
"message_id": "<status-7@example.com>",
"account_id": "work",
"mailbox": "INBOX",
"subject": "Status"
}]
}))
}
fn message_body(&self, message_id: &str) -> Result<Value, String> {
self.calls
.lock()
.unwrap()
.push(("body".to_string(), json!({"message_id": message_id})));
Ok(json!({
"available": true,
"backend": "mock_mail_app",
"id": message_id,
"content_type": "text",
"body": "Project is green.",
"truncated": false
}))
}
fn send(&self, request: &Value) -> Result<Value, String> {
self.calls
.lock()
.unwrap()
.push(("send".to_string(), request.clone()));
let id = if request["draft_only"] == true {
"draft-17"
} else {
"sent-18"
};
if !self.write_completes {
return Ok(json!({
"available": true,
"backend": "mock_mail_app",
"sent": false
}));
}
Ok(json!({
"available": true,
"backend": "mock_mail_app",
"sent": true,
"message_id": id
}))
}
}
#[test]
fn schemas_assign_mail_writes_to_the_approval_tier() {
let defs = mail_tool_defs();
let names: Vec<&str> = defs.iter().filter_map(|def| def["name"].as_str()).collect();
assert_eq!(
names,
[
"mail_inbox",
"mail_search",
"mail_message_body",
"mail_draft",
"mail_send"
]
);
assert!(defs[..3].iter().all(|def| def.get("tier").is_none()));
assert_eq!(defs[3]["mutating"], true);
assert_eq!(defs[4]["mutating"], true);
assert_eq!(defs[3]["tier"], SEND_TIER);
assert_eq!(defs[4]["tier"], SEND_TIER);
assert!(defs[1]["description"]
.as_str()
.is_some_and(|description| description.contains("offer to prepare one reply")));
assert_eq!(
defs[1]["parameters"]["properties"]["limit"]["maximum"],
car_ffi_common::integrations::MESSAGE_READ_LIMIT_CAP
);
}
#[test]
fn mail_writes_gate_until_the_session_has_full_access() {
let defs = mail_tool_defs();
assert_eq!(
super::super::tier_gated_tool_names(
&defs,
car_policy::permission::PermissionTier::ReadOnly,
),
["mail_draft", "mail_send"]
);
assert!(super::super::tier_gated_tool_names(
&defs,
car_policy::permission::PermissionTier::FullAccess,
)
.is_empty());
}
#[test]
fn advertisement_requires_macos_and_granted_mail_automation() {
let granted: Arc<dyn MailBackend> = MockMailBackend::granted();
assert_eq!(MailTools::with_backend(granted, true).tool_defs().len(), 5);
let denied: Arc<dyn MailBackend> = MockMailBackend::with_status("denied");
assert!(MailTools::with_backend(denied, true).tool_defs().is_empty());
let off_platform: Arc<dyn MailBackend> = MockMailBackend::granted();
assert!(MailTools::with_backend(off_platform, false)
.tool_defs()
.is_empty());
}
#[tokio::test]
async fn stale_permission_returns_actionable_mail_remediation() {
let backend: Arc<dyn MailBackend> = MockMailBackend::with_status("denied");
let error = MailTools::with_backend(backend, true)
.execute("mail_inbox", &json!({}))
.await
.unwrap_err();
assert!(
error.contains("Mail Automation access is denied"),
"{error}"
);
assert!(error.contains("System Settings"), "{error}");
assert!(error.contains("Automation"), "{error}");
assert!(error.contains("Mail"), "{error}");
}
#[tokio::test]
async fn reads_are_bounded_and_preserve_both_message_identifiers() {
let backend = MockMailBackend::granted();
let tools = MailTools::with_backend(backend.clone(), true);
let inbox = tools
.execute("mail_inbox", &json!({"account_ids": ["work"]}))
.await
.unwrap();
assert_eq!(inbox["summaries"][0]["unread"], 3);
let rows = tools
.execute(
"mail_search",
&json!({"mailbox": "INBOX", "since": "2026-09-17T00:00:00Z", "limit": 20}),
)
.await
.unwrap();
assert_eq!(rows["messages"][0]["id"], "mailapp:d29yaw:SU5CT1g:7");
assert_eq!(rows["messages"][0]["message_id"], "<status-7@example.com>");
assert_eq!(backend.calls.lock().unwrap()[1].1["include_body"], false);
let error = tools
.execute("mail_search", &json!({"limit": 501}))
.await
.unwrap_err();
assert!(error.contains("between 1 and 500"), "{error}");
assert_eq!(
backend
.calls
.lock()
.unwrap()
.iter()
.filter(|(name, _)| name == "search")
.count(),
1,
"an oversized read must not reach the backend"
);
}
#[tokio::test]
async fn body_uses_the_stable_car_message_id() {
let backend = MockMailBackend::granted();
let result = MailTools::with_backend(backend.clone(), true)
.execute(
"mail_message_body",
&json!({"id": "mailapp:d29yaw:SU5CT1g:7"}),
)
.await
.unwrap();
assert_eq!(result["id"], "mailapp:d29yaw:SU5CT1g:7");
assert_eq!(result["body"], "Project is green.");
assert_eq!(backend.calls.lock().unwrap()[0].0, "body");
}
#[tokio::test]
async fn draft_never_sends_while_send_returns_its_message_id() {
let backend = MockMailBackend::granted();
let tools = MailTools::with_backend(backend.clone(), true);
let params = json!({
"account_id": "work",
"to": ["person@example.com"],
"subject": "Status",
"body": "Project is green."
});
let draft = tools.execute("mail_draft", ¶ms).await.unwrap();
assert_eq!(draft["drafted"], true);
assert_eq!(draft["sent"], false);
assert_eq!(draft["message_id"], "draft-17");
let sent = tools.execute("mail_send", ¶ms).await.unwrap();
assert_eq!(sent["sent"], true);
assert_eq!(sent["message_id"], "sent-18");
let calls = backend.calls.lock().unwrap();
assert_eq!(calls[0].1["draft_only"], true);
assert_eq!(calls[1].1["draft_only"], false);
}
#[tokio::test]
async fn a_send_mail_refused_is_not_a_completed_action() {
let backend = MockMailBackend::write_refused();
let tools = MailTools::with_backend(backend.clone(), true);
let params = json!({
"account_id": "work",
"to": ["person@example.com"],
"subject": "Status",
"body": "Project is green."
});
let error = tools
.execute("mail_send", ¶ms)
.await
.expect_err("Mail reporting the message was not sent is a failed call");
assert!(
error.contains("mail_send") && error.contains("not sent"),
"the error names the tool and what did not happen: {error}"
);
assert_eq!(backend.calls.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn a_draft_mail_refused_is_not_a_completed_action() {
let backend = MockMailBackend::write_refused();
let tools = MailTools::with_backend(backend.clone(), true);
let params = json!({
"account_id": "work",
"to": ["person@example.com"],
"subject": "Status",
"body": "Project is green."
});
let error = tools
.execute("mail_draft", ¶ms)
.await
.expect_err("Mail reporting the draft was not written is a failed call");
assert!(
error.contains("mail_draft") && error.contains("not drafted"),
"the error names the tool and what did not happen: {error}"
);
}
#[tokio::test]
async fn runtime_policy_and_event_log_wrap_mail_dispatch() {
let backend = MockMailBackend::granted();
let tools = Arc::new(MailTools::with_backend(backend.clone(), true));
let executor: Arc<dyn ToolExecutor> = tools;
let runtime = car_engine::Runtime::new().with_executor(executor);
let def = mail_tool_defs()
.into_iter()
.find(|def| def["name"] == "mail_send")
.unwrap();
runtime
.register_tool_entry(
car_engine::ToolEntry::new(super::super::schema_from_def(&def))
.with_side_effects(true),
)
.await;
runtime
.set_capabilities(car_engine::CapabilitySet::new().deny_tool("mail_send"))
.await;
let mut action = Action::new(ActionType::ToolCall);
action.id = "denied-mail-send".to_string();
action.tool = Some("mail_send".to_string());
action.parameters = serde_json::from_value(json!({
"to": ["person@example.com"],
"subject": "Status",
"body": "Project is green."
}))
.unwrap();
let proposal = ActionProposal {
id: "mail-policy-test".to_string(),
source: "test".to_string(),
actions: vec![action],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
};
let result = runtime.execute(&proposal).await;
assert_eq!(result.results[0].status, ActionStatus::Rejected);
assert!(backend.calls.lock().unwrap().is_empty());
let log = runtime.log.lock().await;
let rejection = log
.events()
.iter()
.find(|event| {
event.kind == EventKind::ActionRejected
&& event.action_id.as_deref() == Some("denied-mail-send")
})
.expect("runtime policy rejection must be journaled");
assert_eq!(rejection.data["stage"], "capability");
assert_eq!(rejection.data["attempted"], false);
drop(log);
runtime
.set_capabilities(car_engine::CapabilitySet::new())
.await;
let mut allowed = Action::new(ActionType::ToolCall);
allowed.id = "allowed-mail-send".to_string();
allowed.tool = Some("mail_send".to_string());
allowed.parameters = serde_json::from_value(json!({
"to": ["person@example.com"],
"subject": "Status",
"body": "Project is green."
}))
.unwrap();
let allowed_result = runtime
.execute(&ActionProposal {
id: "mail-event-test".to_string(),
source: "test".to_string(),
actions: vec![allowed],
timestamp: chrono::Utc::now(),
context: HashMap::new(),
})
.await;
assert!(allowed_result.all_succeeded());
assert_eq!(backend.calls.lock().unwrap().len(), 1);
assert!(runtime.log.lock().await.events().iter().any(|event| {
event.kind == EventKind::ActionSucceeded
&& event.action_id.as_deref() == Some("allowed-mail-send")
}));
}
#[tokio::test]
async fn unknown_tool_falls_through() {
let backend: Arc<dyn MailBackend> = MockMailBackend::granted();
let error = MailTools::with_backend(backend, true)
.execute("calendar_events", &json!({}))
.await
.unwrap_err();
assert!(error.starts_with("unknown tool"), "{error}");
}
}