use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::{Availability, IntegrationError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MailAccount {
pub id: String,
pub address: String,
pub display_name: Option<String>,
pub provider_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboxSummary {
pub account_id: String,
pub unread: u32,
pub total: u32,
pub most_recent_subject: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountListing {
#[serde(flatten)]
pub availability: Availability,
pub accounts: Vec<MailAccount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboxListing {
#[serde(flatten)]
pub availability: Availability,
pub summaries: Vec<InboxSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendRequest {
pub account_id: String,
pub to: Vec<String>,
#[serde(default)]
pub cc: Vec<String>,
#[serde(default)]
pub bcc: Vec<String>,
pub subject: String,
pub body: String,
#[serde(default)]
pub draft_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendResult {
#[serde(flatten)]
pub availability: Availability,
pub sent: bool,
pub message_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mailbox {
pub account_id: String,
pub name: String,
pub full_name: String,
pub unread: u32,
pub total: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MailboxListing {
#[serde(flatten)]
pub availability: Availability,
#[serde(default)]
pub mailboxes: Vec<Mailbox>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageSummary {
pub id: String,
pub account_id: String,
pub mailbox: String,
pub subject: Option<String>,
pub sender: Option<String>,
#[serde(default)]
pub recipients: Vec<String>,
pub date_received: Option<DateTime<Utc>>,
pub read: bool,
pub preview: Option<String>,
pub body: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageListing {
#[serde(flatten)]
pub availability: Availability,
pub messages: Vec<MessageSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageQuery {
#[serde(default)]
pub account_ids: Vec<String>,
#[serde(default)]
pub mailbox: Option<String>,
#[serde(default = "default_limit")]
pub limit: usize,
#[serde(default)]
pub since: Option<DateTime<Utc>>,
#[serde(default)]
pub include_body: bool,
}
impl Default for MessageQuery {
fn default() -> Self {
Self {
account_ids: Vec::new(),
mailbox: None,
limit: default_limit(),
since: None,
include_body: false,
}
}
}
fn default_limit() -> usize {
50
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageBodyResult {
#[serde(flatten)]
pub availability: Availability,
pub id: String,
pub content_type: String,
pub body: Option<String>,
pub truncated: bool,
}
pub const DEFAULT_MAILBOX: &str = "INBOX";
pub const MESSAGE_BODY_CAP: usize = 100_000;
const MAILAPP_ID_PREFIX: &str = "mailapp";
const MSGRAPH_ID_PREFIX: &str = "msgraph";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessageRef {
MailApp {
account_id: String,
mailbox: String,
local_id: String,
},
Graph { id: String },
}
fn b64(s: &str) -> String {
URL_SAFE_NO_PAD.encode(s.as_bytes())
}
fn unb64(s: &str) -> Option<String> {
String::from_utf8(URL_SAFE_NO_PAD.decode(s.as_bytes()).ok()?).ok()
}
pub fn encode_message_id(account_id: &str, mailbox: &str, local_id: &str) -> String {
format!(
"{MAILAPP_ID_PREFIX}:{}:{}:{local_id}",
b64(account_id),
b64(mailbox)
)
}
pub fn encode_graph_message_id(graph_id: &str) -> String {
format!("{MSGRAPH_ID_PREFIX}:{graph_id}")
}
pub fn decode_message_id(id: &str) -> Result<MessageRef, IntegrationError> {
let bad = || IntegrationError::Backend(format!("not a CAR mail message id: {id}"));
let (prefix, rest) = id.split_once(':').ok_or_else(bad)?;
match prefix {
MSGRAPH_ID_PREFIX => {
if rest.is_empty() {
return Err(bad());
}
Ok(MessageRef::Graph {
id: rest.to_string(),
})
}
MAILAPP_ID_PREFIX => {
let mut parts = rest.splitn(3, ':');
let account_id = unb64(parts.next().ok_or_else(bad)?).ok_or_else(bad)?;
let mailbox = unb64(parts.next().ok_or_else(bad)?).ok_or_else(bad)?;
let local_id = parts.next().ok_or_else(bad)?.to_string();
if local_id.is_empty() {
return Err(bad());
}
Ok(MessageRef::MailApp {
account_id,
mailbox,
local_id,
})
}
_ => Err(bad()),
}
}
pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
backend::list_accounts()
}
pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
backend::list_inbox(account_ids)
}
pub fn list_mailboxes(account_ids: &[String]) -> Result<MailboxListing, IntegrationError> {
backend::list_mailboxes(account_ids)
}
pub fn list_messages(query: MessageQuery) -> Result<MessageListing, IntegrationError> {
backend::list_messages(query)
}
pub fn message_body(message_id: &str) -> Result<MessageBodyResult, IntegrationError> {
backend::message_body(message_id)
}
pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
backend::send(req)
}
#[cfg(target_os = "macos")]
mod backend {
use super::*;
pub(super) const JXA: &str = r#"
function normalizeAccount(account) {
let name = "";
let id = "";
let addresses = [];
try { name = String(account.name()); } catch (e) {}
try { id = String(account.id()); } catch (e) {}
if (!id) id = name;
try { addresses = account.emailAddresses().map(String); } catch (e) {}
return {
id: id,
address: addresses[0] || name,
display_name: name || null,
provider_hint: null
};
}
function accountMatches(account, requested) {
if (requested.length === 0) return true;
const normalized = normalizeAccount(account);
return requested.indexOf(normalized.id) >= 0 || requested.indexOf(normalized.address) >= 0 || requested.indexOf(normalized.display_name || "") >= 0;
}
function mailApp() {
const app = Application("/System/Applications/Mail.app");
app.includeStandardAdditions = true;
return app;
}
// A `byName` specifier is lazy — it resolves (or throws) only when a property
// is read off it, so "did I get a real mailbox" needs an actual touch.
function mailboxUsable(box) {
try { box.name(); return true; } catch (e) { return false; }
}
// Depth-first walk of every mailbox under `container` (an account or a
// mailbox), yielding {name, full_name, box}. Counts are deliberately NOT read
// here: resolution doesn't need them, and reading them is what makes an
// enumeration expensive.
function walkMailboxes(container, prefix, out, depth) {
let boxes = [];
try { boxes = container.mailboxes(); } catch (e) { return; }
for (let i = 0; i < boxes.length; i++) {
const box = boxes[i];
let name = "";
try { name = String(box.name()); } catch (e) { continue; }
const full = prefix ? prefix + "/" + name : name;
out.push({name: name, full_name: full, box: box});
if (depth < 8) walkMailboxes(box, full, out, depth + 1);
}
}
// Resolve a mailbox selector: the fast `byName` path first (which is what
// INBOX always hits), then a full-path match, then a leaf-name match. The last
// two are case-insensitive because mailbox names are localized and users type
// "travel".
//
// Returns `{box, full_name}`, not the bare box: the RESOLVED path has to
// travel with the mailbox so a row can report where it actually came from
// instead of echoing whatever selector the caller typed. A caller that asked
// for "travel" and got rows stamped "travel" cannot match them against
// `mail.mailboxes` output; stamped "Travel/2026" it can.
function resolveMailbox(account, wanted) {
const target = String(wanted || "INBOX");
let direct = null;
try { direct = account.mailboxes.byName(target); } catch (e) {}
if (direct && mailboxUsable(direct)) {
// `byName` only ever reaches a top-level mailbox, so its own name IS the
// full path — read it back so the casing is Mail's, not the caller's.
let name = target;
try { name = String(direct.name()); } catch (e) {}
return {box: direct, full_name: name};
}
const all = [];
walkMailboxes(account, "", all, 0);
const lower = target.toLowerCase();
for (let i = 0; i < all.length; i++) {
if (all[i].full_name.toLowerCase() === lower) return {box: all[i].box, full_name: all[i].full_name};
}
for (let i = 0; i < all.length; i++) {
if (all[i].name.toLowerCase() === lower) return {box: all[i].box, full_name: all[i].full_name};
}
return null;
}
// Newest first. `date_received` is an ISO-8601 `Z` string, so a plain string
// compare IS the chronological compare; rows with no date sort to the end
// rather than to the front.
function byDateDesc(a, b) {
const x = String(a.date_received || "");
const y = String(b.date_received || "");
if (x === y) return 0;
return x < y ? 1 : -1;
}
// "No account matched what you asked for" must not read as "you have no mail".
function unmatchedAccounts(requested) {
return "no mail account matched " + requested.join(", ") + " — list them with mail.accounts";
}
function truncateBody(text, cap) {
const s = String(text);
if (cap > 0 && s.length > cap) return {body: s.slice(0, cap), truncated: true};
return {body: s, truncated: false};
}
function run(argv) {
const mode = argv[0] || "accounts";
let Mail;
try {
Mail = mailApp();
} catch (e) {
return JSON.stringify({available:false, backend:"mail_app", reason:String(e), accounts:[], summaries:[], mailboxes:[], messages:[], content_type:"text", body:null, truncated:false, sent:false, message_id:null});
}
if (mode === "accounts") {
try {
return JSON.stringify({available:true, backend:"mail_app", reason:null, accounts: Mail.accounts().map(normalizeAccount)});
} catch (e) {
return JSON.stringify({available:false, backend:"mail_app", reason:String(e), accounts:[]});
}
}
if (mode === "inbox") {
const requested = argv.slice(1);
const summaries = [];
try {
Mail.accounts().forEach(account => {
if (!accountMatches(account, requested)) return;
const normalized = normalizeAccount(account);
let unread = 0;
let total = 0;
let subject = null;
try {
const inbox = account.mailboxes.byName("INBOX");
const messages = inbox.messages();
total = messages.length;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
try { if (message.readStatus() === false) unread += 1; } catch (e) {}
if (subject === null) {
try { subject = String(message.subject()); } catch (e) {}
}
}
} catch (e) {}
summaries.push({account_id: normalized.id, unread: unread, total: total, most_recent_subject: subject});
});
return JSON.stringify({available:true, backend:"mail_app", reason:null, summaries:summaries});
} catch (e) {
return JSON.stringify({available:false, backend:"mail_app", reason:String(e), summaries:[]});
}
}
if (mode === "mailboxes") {
const requested = argv.slice(1);
const mailboxes = [];
let matchedAccounts = 0;
try {
Mail.accounts().forEach(account => {
if (!accountMatches(account, requested)) return;
matchedAccounts += 1;
const normalized = normalizeAccount(account);
const all = [];
walkMailboxes(account, "", all, 0);
for (let i = 0; i < all.length; i++) {
let unread = 0;
let total = 0;
try { unread = Number(all[i].box.unreadCount()); } catch (e) {}
// `.length` on the element specifier is a `count` Apple Event — one
// round trip. Calling `messages()` first would materialize every
// specifier in the mailbox just to count them.
try { total = Number(all[i].box.messages.length); } catch (e) {}
mailboxes.push({
account_id: normalized.id,
name: all[i].name,
full_name: all[i].full_name,
unread: isFinite(unread) ? unread : 0,
total: isFinite(total) ? total : 0
});
}
});
// An `account_ids` filter that matched nothing is a caller error, not an
// account with no folders — say so instead of returning an empty list.
if (matchedAccounts === 0 && requested.length > 0) {
return JSON.stringify({available:false, backend:"mail_app", reason:unmatchedAccounts(requested), mailboxes:[]});
}
return JSON.stringify({available:true, backend:"mail_app", reason:null, mailboxes:mailboxes});
} catch (e) {
return JSON.stringify({available:false, backend:"mail_app", reason:String(e), mailboxes:[]});
}
}
if (mode === "messages") {
try {
const q = JSON.parse(argv[1] || "{}");
const requested = q.account_ids || [];
const wanted = q.mailbox || "INBOX";
const limit = q.limit > 0 ? q.limit : 50;
const since = q.since ? String(q.since) : null;
const includeBody = q.include_body === true;
const bodyCap = q.body_cap > 0 ? q.body_cap : 100000;
// Every matched account contributes into ONE candidate array, which is
// sorted and sliced ONCE at the end. Sorting and slicing per account and
// concatenating makes the answer depend on account order: with an iCloud
// and an Exchange account both holding a "Travel" mailbox, `limit: 1`
// returns iCloud's newest message even when Exchange holds a newer one,
// and `limit: 10` returns rows that are not in date order at all. That is
// the same silent miss this surface exists to end, one level down
// (Parslee-ai/car-releases#84) — and "newest first" is a documented
// contract, not a best effort.
const candidates = [];
let matchedAccounts = 0;
let resolvedMailbox = false;
Mail.accounts().forEach(account => {
if (!accountMatches(account, requested)) return;
matchedAccounts += 1;
const normalized = normalizeAccount(account);
const hit = resolveMailbox(account, wanted);
if (!hit) return;
resolvedMailbox = true;
const msgs = hit.box.messages;
// Bulk array property gets: five Apple Events for the WHOLE mailbox,
// whatever its size. The pre-existing inbox walk above reads each
// property off each message individually, which is one Apple Event per
// message per field — that is why a full mailbox scan flirts with the
// 15s host timeout, and it is the thing not to repeat here.
let ids = null, subjects = null, senders = null, dates = null, reads = null;
try { ids = msgs.id(); } catch (e) {}
try { subjects = msgs.subject(); } catch (e) {}
try { senders = msgs.sender(); } catch (e) {}
try { dates = msgs.dateReceived(); } catch (e) {}
try { reads = msgs.readStatus(); } catch (e) {}
const items = [];
if (ids !== null) {
for (let i = 0; i < ids.length; i++) {
let iso = null;
try { if (dates && dates[i]) iso = new Date(dates[i]).toISOString(); } catch (e) {}
items.push({
msgs: msgs,
account_id: normalized.id,
mailbox: hit.full_name,
index: i,
local_id: String(ids[i]),
subject: subjects && subjects[i] != null ? String(subjects[i]) : null,
sender: senders && senders[i] != null ? String(senders[i]) : null,
date_received: iso,
read: reads ? reads[i] === true : false
});
}
} else {
// Fallback when a bulk get throws (some IMAP accounts refuse them):
// per-message reads, but bounded — never a whole-mailbox loop.
let count = 0;
try { count = Number(msgs.length); } catch (e) {}
const scan = Math.min(count, limit * 4);
for (let i = 0; i < scan; i++) {
try {
const m = msgs[i];
let iso = null;
try { iso = new Date(m.dateReceived()).toISOString(); } catch (e) {}
items.push({
msgs: msgs,
account_id: normalized.id,
mailbox: hit.full_name,
index: i,
local_id: String(m.id()),
subject: (function(){ try { return String(m.subject()); } catch (e) { return null; } })(),
sender: (function(){ try { return String(m.sender()); } catch (e) { return null; } })(),
date_received: iso,
read: (function(){ try { return m.readStatus() === true; } catch (e) { return false; } })()
});
} catch (e) {}
}
}
// Per-account: newest first, then `since`, then at most `limit`
// forwarded to the combined pool. Filtering before the slice is what
// makes a narrow `since` window return real matches instead of
// whatever happened to land in the first `limit` rows. Capping at
// `limit` here is lossless for the global answer — `since` keeps a
// PREFIX of a newest-first list, so an account can never own a
// globally-selected row from beyond its own newest `limit` — and it
// keeps one 200k-message mailbox from dominating the combined sort.
items.sort(byDateDesc);
let taken = 0;
for (let i = 0; i < items.length && taken < limit; i++) {
if (since && (!items[i].date_received || items[i].date_received < since)) continue;
candidates.push(items[i]);
taken += 1;
}
});
// "No account matched what you asked for" and "that mailbox does not
// exist here" must NOT look like "that mailbox is empty" — the
// silent-empty answer is the whole failure this surface exists to end
// (Parslee-ai/car-releases#84).
if (matchedAccounts === 0 && requested.length > 0) {
return JSON.stringify({available:false, backend:"mail_app", reason:unmatchedAccounts(requested), messages:[]});
}
if (matchedAccounts > 0 && !resolvedMailbox) {
return JSON.stringify({available:false, backend:"mail_app", reason:"no mailbox named "+wanted+" in the selected account(s) — list them with mail.mailboxes", messages:[]});
}
// The ONE global sort. Only the rows that survive it pay for the per-row
// Apple Events below, so a multi-account read costs no more round trips
// than a single-account one did.
candidates.sort(byDateDesc);
const rows = [];
for (let i = 0; i < candidates.length && rows.length < limit; i++) {
const item = candidates[i];
const row = {
account_id: item.account_id,
mailbox: item.mailbox,
local_id: item.local_id,
subject: item.subject,
sender: item.sender,
recipients: [],
date_received: item.date_received,
read: item.read,
preview: null,
body: null
};
// Per-row reads, bounded by `limit` rather than by mailbox size.
try { row.recipients = item.msgs[item.index].toRecipients.address().map(String); } catch (e) {}
if (includeBody) {
try {
const cut = truncateBody(item.msgs[item.index].content(), bodyCap);
row.body = cut.body;
row.preview = cut.body.slice(0, 200);
} catch (e) {}
}
rows.push(row);
}
return JSON.stringify({available:true, backend:"mail_app", reason:null, messages:rows});
} catch (e) {
return JSON.stringify({available:false, backend:"mail_app", reason:String(e), messages:[]});
}
}
if (mode === "body") {
const accountId = String(argv[1] || "");
const wanted = String(argv[2] || "INBOX");
const localId = String(argv[3] || "");
const bodyCap = Number(argv[4] || 100000);
try {
let found = null;
const accounts = Mail.accounts();
for (let a = 0; a < accounts.length && !found; a++) {
if (!accountMatches(accounts[a], [accountId])) continue;
const hit = resolveMailbox(accounts[a], wanted);
if (!hit) continue;
const box = hit.box;
const numeric = parseInt(localId, 10);
if (isFinite(numeric)) {
try {
const hits = box.messages.whose({id: numeric})();
if (hits.length > 0) found = hits[0];
} catch (e) {}
}
if (!found) {
// `whose` is unsupported on some account types; fall back to one
// bulk id fetch plus an index lookup, not a per-message probe.
try {
const ids = box.messages.id();
for (let i = 0; i < ids.length; i++) {
if (String(ids[i]) === localId) { found = box.messages[i]; break; }
}
} catch (e) {}
}
}
if (!found) {
return JSON.stringify({available:false, backend:"mail_app", reason:"message "+localId+" not found in mailbox "+wanted, content_type:"text", body:null, truncated:false});
}
let raw = null;
try { raw = found.content(); } catch (e) {
return JSON.stringify({available:false, backend:"mail_app", reason:String(e), content_type:"text", body:null, truncated:false});
}
const cut = truncateBody(raw === null ? "" : raw, bodyCap);
return JSON.stringify({available:true, backend:"mail_app", reason:null, content_type:"text", body:cut.body, truncated:cut.truncated});
} catch (e) {
return JSON.stringify({available:false, backend:"mail_app", reason:String(e), content_type:"text", body:null, truncated:false});
}
}
if (mode === "send") {
try {
const req = JSON.parse(argv[1] || "{}");
const msg = Mail.OutgoingMessage({
subject: req.subject || "",
content: req.body || "",
visible: false
});
Mail.outgoingMessages.push(msg);
(req.to || []).forEach(address => msg.toRecipients.push(Mail.Recipient({address: String(address)})));
(req.cc || []).forEach(address => msg.ccRecipients.push(Mail.Recipient({address: String(address)})));
(req.bcc || []).forEach(address => msg.bccRecipients.push(Mail.Recipient({address: String(address)})));
if (req.account_id) {
const accounts = Mail.accounts();
let matched = null;
for (let i = 0; i < accounts.length; i++) {
const normalized = normalizeAccount(accounts[i]);
if (normalized.id === req.account_id || normalized.address === req.account_id || normalized.display_name === req.account_id) {
matched = normalized;
break;
}
}
// A specified-but-unresolvable account must NOT silently fall
// through to Mail's default outgoing account (car-releases#47).
if (!matched) {
return JSON.stringify({available:true, backend:"mail_app", reason:"sender_override_failed: requested account "+String(req.account_id)+" not found", sent:false, message_id:null});
}
// The JXA `sender` setter throws under some Mail/account-type
// combos (EWS vs IMAP) and can also no-op without throwing.
// Set it, then read it back and confirm the address actually
// took — never assume success.
let setError = null;
try { msg.sender(matched.address); } catch (e) { setError = String(e); }
let effective = null;
try { effective = String(msg.sender()); } catch (e) {}
const wanted = String(matched.address || "").toLowerCase();
const took = wanted.length > 0 && effective !== null &&
String(effective).toLowerCase().indexOf(wanted) !== -1;
if (!took) {
return JSON.stringify({available:true, backend:"mail_app", reason:"sender_override_failed: requested "+matched.address+", effective "+String(effective)+(setError ? " (setter error: "+setError+")" : ""), sent:false, message_id:null});
}
}
if (req.draft_only) {
msg.save();
} else {
msg.send();
}
let messageId = null;
try { messageId = String(msg.id()); } catch (e) {}
return JSON.stringify({available:true, backend:"mail_app", reason:null, sent:true, message_id:messageId});
} catch (e) {
return JSON.stringify({available:false, backend:"mail_app", reason:String(e), sent:false, message_id:null});
}
}
return JSON.stringify({available:false, backend:"mail_app", reason:"unknown mail mode", accounts:[], summaries:[], mailboxes:[], messages:[], content_type:"text", body:null, truncated:false, sent:false, message_id:null});
}
"#;
pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
let mail_listing: AccountListing = run_jxa(&["accounts"])?;
if mail_listing.availability.available || !mail_listing.accounts.is_empty() {
return Ok(mail_listing);
}
let accounts = car_accounts::list()
.map_err(|e| IntegrationError::Backend(format!("accounts fallback: {e}")))?
.accounts
.into_iter()
.filter(|account| account.capabilities.iter().any(|cap| cap == "mail"))
.map(|account| MailAccount {
id: account.id,
address: account.identifier.unwrap_or(account.label.clone()),
display_name: Some(account.label),
provider_hint: Some(account.provider),
})
.collect();
Ok(AccountListing {
availability: Availability::available("internet_accounts"),
accounts,
})
}
pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
let mut args = vec!["inbox"];
args.extend(account_ids.iter().map(String::as_str));
run_jxa(&args)
}
pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
let req_json = serde_json::to_string(&req)
.map_err(|e| IntegrationError::Backend(format!("mail request json: {e}")))?;
run_jxa(&["send", req_json.as_str()])
}
pub fn list_mailboxes(account_ids: &[String]) -> Result<MailboxListing, IntegrationError> {
let mut args = vec!["mailboxes"];
args.extend(account_ids.iter().map(String::as_str));
run_jxa(&args)
}
#[derive(Deserialize)]
struct RawMessage {
#[serde(default)]
account_id: String,
#[serde(default)]
mailbox: String,
#[serde(default)]
local_id: String,
subject: Option<String>,
sender: Option<String>,
#[serde(default)]
recipients: Vec<String>,
date_received: Option<DateTime<Utc>>,
#[serde(default)]
read: bool,
preview: Option<String>,
body: Option<String>,
}
#[derive(Deserialize)]
struct RawMessageListing {
#[serde(flatten)]
availability: Availability,
#[serde(default)]
messages: Vec<RawMessage>,
}
#[derive(Deserialize)]
struct RawBodyResult {
#[serde(flatten)]
availability: Availability,
#[serde(default)]
content_type: String,
body: Option<String>,
#[serde(default)]
truncated: bool,
}
pub fn list_messages(query: MessageQuery) -> Result<MessageListing, IntegrationError> {
let mailbox = query
.mailbox
.clone()
.unwrap_or_else(|| DEFAULT_MAILBOX.to_string());
let payload = serde_json::json!({
"account_ids": query.account_ids,
"mailbox": mailbox,
"limit": query.limit.clamp(1, 500),
"since": query.since.map(|t| t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()),
"include_body": query.include_body,
"body_cap": MESSAGE_BODY_CAP,
})
.to_string();
let raw: RawMessageListing = run_jxa(&["messages", payload.as_str()])?;
Ok(MessageListing {
availability: raw.availability,
messages: raw
.messages
.into_iter()
.map(|m| MessageSummary {
id: encode_message_id(&m.account_id, &m.mailbox, &m.local_id),
account_id: m.account_id,
mailbox: m.mailbox,
subject: m.subject,
sender: m.sender,
recipients: m.recipients,
date_received: m.date_received,
read: m.read,
preview: m.preview,
body: m.body,
})
.collect(),
})
}
pub fn message_body(message_id: &str) -> Result<MessageBodyResult, IntegrationError> {
let (account_id, mailbox, local_id) = match decode_message_id(message_id)? {
MessageRef::MailApp {
account_id,
mailbox,
local_id,
} => (account_id, mailbox, local_id),
MessageRef::Graph { .. } => {
return Err(IntegrationError::Backend(format!(
"message id {message_id} belongs to the Microsoft Graph backend, \
not to Mail.app"
)))
}
};
let cap = MESSAGE_BODY_CAP.to_string();
let raw: RawBodyResult = run_jxa(&[
"body",
account_id.as_str(),
mailbox.as_str(),
local_id.as_str(),
cap.as_str(),
])?;
Ok(MessageBodyResult {
availability: raw.availability,
id: message_id.to_string(),
content_type: if raw.content_type.is_empty() {
"text".to_string()
} else {
raw.content_type
},
body: raw.body,
truncated: raw.truncated,
})
}
fn run_jxa<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
crate::jxa::run(JXA, args, crate::jxa::DEFAULT_TIMEOUT)
}
}
#[cfg(not(target_os = "macos"))]
mod backend {
use super::*;
pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(AccountListing {
availability: Availability::available("msgraph"),
accounts: vec![MailAccount {
id: "msgraph".into(),
address: String::new(),
display_name: Some("Microsoft 365".into()),
provider_hint: Some("microsoft".into()),
}],
});
}
Ok(AccountListing {
availability: current_backend_pending(),
accounts: vec![],
})
}
pub fn list_inbox(_account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::inbox_summary("msgraph") {
Ok(summary) => InboxListing {
availability: Availability::available("msgraph"),
summaries: vec![summary],
},
Err(e) => InboxListing {
availability: Availability::pending("msgraph", e.to_string()),
summaries: vec![],
},
});
}
Ok(InboxListing {
availability: current_backend_pending(),
summaries: vec![],
})
}
pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::send_mail(&req) {
Ok(message_id) => SendResult {
availability: Availability::available("msgraph"),
sent: true,
message_id,
},
Err(e) => SendResult {
availability: Availability::pending("msgraph", e.to_string()),
sent: false,
message_id: None,
},
});
}
Ok(SendResult {
availability: current_backend_pending(),
sent: false,
message_id: None,
})
}
pub fn list_mailboxes(_account_ids: &[String]) -> Result<MailboxListing, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::mail_folders("msgraph") {
Ok(mailboxes) => MailboxListing {
availability: Availability::available("msgraph"),
mailboxes,
},
Err(e) => MailboxListing {
availability: Availability::pending("msgraph", e.to_string()),
mailboxes: vec![],
},
});
}
Ok(MailboxListing {
availability: current_backend_pending(),
mailboxes: vec![],
})
}
pub fn list_messages(query: MessageQuery) -> Result<MessageListing, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::messages("msgraph", &query) {
Ok(messages) => MessageListing {
availability: Availability::available("msgraph"),
messages,
},
Err(e) => MessageListing {
availability: Availability::pending("msgraph", e.to_string()),
messages: vec![],
},
});
}
Ok(MessageListing {
availability: current_backend_pending(),
messages: vec![],
})
}
pub fn message_body(message_id: &str) -> Result<MessageBodyResult, IntegrationError> {
let graph_id = match decode_message_id(message_id)? {
MessageRef::Graph { id } => id,
MessageRef::MailApp { .. } => {
return Err(IntegrationError::Backend(format!(
"message id {message_id} belongs to the macOS Mail.app backend, \
which is not available on this platform"
)))
}
};
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::message_body(&graph_id) {
Ok(mut r) => {
r.id = message_id.to_string();
r
}
Err(e) => MessageBodyResult {
availability: Availability::pending("msgraph", e.to_string()),
id: message_id.to_string(),
content_type: "text".into(),
body: None,
truncated: false,
},
});
}
Ok(MessageBodyResult {
availability: current_backend_pending(),
id: message_id.to_string(),
content_type: "text".into(),
body: None,
truncated: false,
})
}
fn current_backend_pending() -> Availability {
Availability::pending(
"imap_smtp",
"Set CAR_MSGRAPH_CLIENT_ID (Azure AD app) to enable the Microsoft \
Graph mail backend (car#520/#531 — inbox + send/draft; \
car-releases#84 — mailboxes, message rows, bodies); a local \
IMAP/SMTP backend is not yet wired.",
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn message_id_round_trips_through_awkward_names() {
for (account, mailbox, local) in [
("iCloud", "INBOX", "12345"),
("Exchange: work", "Travel/2026 — flights", "7"),
("a:b:c", "x:y:z", "0"),
("", "", "1"),
("Ünïcode ✈", "Ordner/Reisen", "99999999"),
] {
let encoded = encode_message_id(account, mailbox, local);
assert!(
!encoded[MAILAPP_ID_PREFIX.len() + 1..].contains(' '),
"encoded id must be argv-safe: {encoded}"
);
assert_eq!(
decode_message_id(&encoded).unwrap(),
MessageRef::MailApp {
account_id: account.to_string(),
mailbox: mailbox.to_string(),
local_id: local.to_string(),
}
);
}
}
#[test]
fn graph_message_id_round_trips() {
let raw = "AAMkAGI2T=Gt-Zg_AAA";
assert_eq!(
decode_message_id(&encode_graph_message_id(raw)).unwrap(),
MessageRef::Graph {
id: raw.to_string()
}
);
}
#[test]
fn decode_rejects_ids_it_did_not_mint() {
for bad in [
"",
"12345",
"mailapp",
"mailapp:only-two:parts",
"mailapp:aQ:aQ:",
"mailapp:!!!:aQ:1",
"msgraph:",
"imap:aQ:aQ:1",
] {
assert!(
decode_message_id(bad).is_err(),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn message_query_defaults_to_inbox_and_fifty() {
let q: MessageQuery = serde_json::from_str("{}").unwrap();
assert!(q.mailbox.is_none());
assert_eq!(q.limit, 50);
assert!(q.account_ids.is_empty());
assert!(q.since.is_none());
assert!(!q.include_body);
assert_eq!(
q.mailbox.unwrap_or_else(|| DEFAULT_MAILBOX.to_string()),
"INBOX"
);
}
#[test]
fn message_query_parses_a_full_payload() {
let q: MessageQuery = serde_json::from_str(
r#"{"account_ids":["work"],"mailbox":"Travel","limit":5,
"since":"2026-01-01T00:00:00Z","include_body":true}"#,
)
.unwrap();
assert_eq!(q.account_ids, vec!["work".to_string()]);
assert_eq!(q.mailbox.as_deref(), Some("Travel"));
assert_eq!(q.limit, 5);
assert_eq!(q.since.unwrap().to_rfc3339(), "2026-01-01T00:00:00+00:00");
assert!(q.include_body);
}
#[cfg(target_os = "macos")]
mod jxa {
use super::*;
const MOCK: &str = r#"
function mkMessages(rows) {
const api = function (i) { return api[i]; };
rows.forEach(function (r, i) {
api[i] = {
id: function () { return r.id; },
subject: function () { return r.subject; },
sender: function () { return r.sender; },
dateReceived: function () { return r.date; },
readStatus: function () { return r.read === true; },
content: function () { return "body of " + r.id; },
toRecipients: { address: function () { return ["me@example.com"]; } }
};
});
api.length = rows.length;
api.id = function () { return rows.map(function (r) { return r.id; }); };
api.subject = function () { return rows.map(function (r) { return r.subject; }); };
api.sender = function () { return rows.map(function (r) { return r.sender; }); };
api.dateReceived = function () { return rows.map(function (r) { return r.date; }); };
api.readStatus = function () { return rows.map(function (r) { return r.read === true; }); };
api.whose = function () { return function () { return []; }; };
return api;
}
function mkBox(name, rows, children) {
const kids = children || [];
return {
name: function () { return name; },
unreadCount: function () { return 0; },
messages: mkMessages(rows),
mailboxes: function () { return kids; }
};
}
function mkAccount(id, address, boxes) {
const list = function () { return boxes; };
list.byName = function (n) {
for (let i = 0; i < boxes.length; i++) if (boxes[i].name() === n) return boxes[i];
throw new Error("no mailbox " + n);
};
return {
id: function () { return id; },
name: function () { return address; },
emailAddresses: function () { return [address]; },
mailboxes: list
};
}
const ACC1 = mkAccount("ACC-1", "one@example.com", [
mkBox("INBOX", [{id: 11, subject: "inbox one", sender: "a@x", date: "2026-03-03T00:00:00Z"}]),
mkBox("Travel", [
{id: 101, subject: "acc1 aug", sender: "air@x", date: "2026-08-01T00:00:00Z"},
{id: 102, subject: "acc1 jan", sender: "air@x", date: "2026-01-01T00:00:00Z"}
], [
mkBox("2026", [{id: 103, subject: "nested", sender: "air@x", date: "2026-05-05T00:00:00Z"}])
])
]);
const ACC2 = mkAccount("ACC-2", "two@example.com", [
mkBox("INBOX", [{id: 21, subject: "inbox two", sender: "b@x", date: "2026-04-04T00:00:00Z"}]),
mkBox("Travel", [{id: 201, subject: "acc2 newest", sender: "air@x", date: "2026-08-20T00:00:00Z"}])
]);
const MOCK_APP = {accounts: function () { return [ACC1, ACC2]; }, includeStandardAdditions: false};
mailApp = function () { return MOCK_APP; };
"#;
fn run(args: &[&str]) -> serde_json::Value {
let script = format!("{}\n{MOCK}", super::super::backend::JXA);
let out = crate::jxa::run_raw(&script, args, crate::jxa::DEFAULT_TIMEOUT)
.expect("osascript should run the stubbed script");
serde_json::from_slice(&out).expect("stubbed script should emit JSON")
}
fn messages(query: serde_json::Value) -> serde_json::Value {
run(&["messages", &query.to_string()])
}
fn dates(v: &serde_json::Value) -> Vec<String> {
v["messages"]
.as_array()
.unwrap()
.iter()
.map(|m| m["date_received"].as_str().unwrap_or("").to_string())
.collect()
}
#[test]
fn newest_first_is_global_across_accounts_not_per_account() {
let one = messages(serde_json::json!({"mailbox": "Travel", "limit": 1}));
assert_eq!(dates(&one), vec!["2026-08-20T00:00:00.000Z"]);
assert_eq!(one["messages"][0]["account_id"], "ACC-2");
let all = messages(serde_json::json!({"mailbox": "Travel", "limit": 10}));
assert_eq!(
dates(&all),
vec![
"2026-08-20T00:00:00.000Z",
"2026-08-01T00:00:00.000Z",
"2026-01-01T00:00:00.000Z",
]
);
}
#[test]
fn limit_is_shared_across_accounts() {
let two = messages(serde_json::json!({"mailbox": "Travel", "limit": 2}));
assert_eq!(two["messages"].as_array().unwrap().len(), 2);
assert_eq!(
dates(&two),
vec!["2026-08-20T00:00:00.000Z", "2026-08-01T00:00:00.000Z"]
);
}
#[test]
fn nested_mailboxes_resolve_by_path_by_leaf_and_case_insensitively() {
for selector in ["Travel/2026", "travel/2026", "2026"] {
let v = messages(serde_json::json!({"mailbox": selector}));
assert_eq!(
v["messages"][0]["subject"], "nested",
"selector {selector:?} should reach the nested mailbox"
);
assert_eq!(v["messages"][0]["mailbox"], "Travel/2026");
}
let cased = messages(serde_json::json!({"mailbox": "travel", "limit": 1}));
assert_eq!(cased["messages"][0]["mailbox"], "Travel");
}
#[test]
fn since_is_inclusive_at_the_instant_and_exclusive_one_ms_later() {
let at = messages(serde_json::json!({
"mailbox": "Travel", "account_ids": ["ACC-1"],
"since": "2026-08-01T00:00:00.000Z"
}));
assert_eq!(dates(&at), vec!["2026-08-01T00:00:00.000Z"]);
let past = messages(serde_json::json!({
"mailbox": "Travel", "account_ids": ["ACC-1"],
"since": "2026-08-01T00:00:00.001Z"
}));
assert!(past["messages"].as_array().unwrap().is_empty());
}
#[test]
fn since_filters_before_the_limit() {
let v = messages(serde_json::json!({
"mailbox": "Travel", "limit": 1, "since": "2026-02-01T00:00:00.000Z"
}));
assert_eq!(dates(&v), vec!["2026-08-20T00:00:00.000Z"]);
}
#[test]
fn unreachable_targets_report_a_reason_rather_than_an_empty_list() {
let no_box = messages(serde_json::json!({"mailbox": "Nope"}));
assert_eq!(no_box["available"], false);
assert!(no_box["reason"]
.as_str()
.unwrap()
.contains("mail.mailboxes"));
let no_account =
messages(serde_json::json!({"mailbox": "Travel", "account_ids": ["ACC-NOPE"]}));
assert_eq!(no_account["available"], false);
assert!(no_account["reason"].as_str().unwrap().contains("ACC-NOPE"));
let no_account_boxes = run(&["mailboxes", "ACC-NOPE"]);
assert_eq!(no_account_boxes["available"], false);
assert!(no_account_boxes["reason"]
.as_str()
.unwrap()
.contains("mail.accounts"));
}
#[test]
fn mailbox_enumeration_includes_nested_mailboxes() {
let v = run(&["mailboxes"]);
let names: Vec<&str> = v["mailboxes"]
.as_array()
.unwrap()
.iter()
.map(|m| m["full_name"].as_str().unwrap())
.collect();
assert!(names.contains(&"Travel"), "{names:?}");
assert!(names.contains(&"Travel/2026"), "{names:?}");
}
#[test]
fn an_empty_query_still_reads_the_inbox() {
let v = messages(serde_json::json!({}));
let subjects: Vec<&str> = v["messages"]
.as_array()
.unwrap()
.iter()
.map(|m| m["subject"].as_str().unwrap())
.collect();
assert_eq!(subjects, vec!["inbox two", "inbox one"]);
}
#[test]
fn row_ids_decode_to_the_account_and_resolved_mailbox() {
let v = messages(serde_json::json!({"mailbox": "2026"}));
let account = v["messages"][0]["account_id"].as_str().unwrap();
let mailbox = v["messages"][0]["mailbox"].as_str().unwrap();
let local = v["messages"][0]["local_id"].as_str().unwrap();
let id = encode_message_id(account, mailbox, local);
assert_eq!(
decode_message_id(&id).unwrap(),
MessageRef::MailApp {
account_id: "ACC-1".into(),
mailbox: "Travel/2026".into(),
local_id: "103".into(),
}
);
let body = run(&["body", "ACC-1", "Travel/2026", "103", "100000"]);
assert_eq!(body["available"], true);
assert_eq!(body["body"], "body of 103");
}
}
#[test]
fn listings_carry_the_availability_envelope_inline() {
let listing = MailboxListing {
availability: Availability::available("mail_app"),
mailboxes: vec![Mailbox {
account_id: "iCloud".into(),
name: "Travel".into(),
full_name: "Travel".into(),
unread: 2,
total: 17,
}],
};
let v = serde_json::to_value(&listing).unwrap();
assert_eq!(v["available"], serde_json::json!(true));
assert_eq!(v["backend"], serde_json::json!("mail_app"));
assert_eq!(v["mailboxes"][0]["full_name"], serde_json::json!("Travel"));
}
}