Skip to main content

car_integrations/mail/
mod.rs

1//! Mail capability — enumerate accounts and mailboxes, summarize inbox, read
2//! message rows and bodies, send/draft.
3//!
4//! The read surface is deliberately three calls rather than one: discover
5//! ([`list_mailboxes`]), read rows ([`list_messages`]), then fetch a body
6//! ([`message_body`]). It used to be [`list_inbox`] alone, which could only
7//! ever see INBOX and returned no message id — so an agent looking for a
8//! flight confirmation that a mail rule had already filed into "Travel" got an
9//! empty result and said "no upcoming travel". That answer is *silently*
10//! wrong: it reads as "no results" rather than "cannot see there"
11//! (Parslee-ai/car-releases#84). Anything added here should preserve that
12//! distinction — return `available: false` with a reason rather than an empty
13//! list whenever the backend could not look.
14//!
15//! Unlike Calendar + Contacts, Mail has no clean *local* OS-native read API
16//! off macOS — a real, structural parity gap, not merely unwired plumbing:
17//!
18//! - **macOS** — the one platform with a zero-setup local backend: JXA against
19//!   Mail.app (below).
20//! - **Windows** — there is no populated local mail store to read. The People
21//!   and Calendar apps aggregate the user's connected accounts into the WinRT
22//!   Contacts/Appointments stores (which is exactly why `windows_contacts` /
23//!   `windows_calendar` read locally, unpackaged), but *no* modern mail app
24//!   plays the equivalent `Windows.ApplicationModel.Email.EmailStore`
25//!   data-provider role — the old Mail app is deprecated and the new Outlook is
26//!   cloud-only. Verified 2026-07-26 from an unpackaged exe on a box with real
27//!   connected accounts (Contacts/Calendar returned data):
28//!   `EmailManager::RequestStoreAsync` succeeds but `FindMailboxesAsync`
29//!   returns **0 mailboxes** for both `AppMailboxesReadWrite` and
30//!   `AllMailboxesLimitedReadWrite`. Whether that 0 is the restricted `email`
31//!   capability silently denying enumeration for an unpackaged process or the
32//!   absent data provider, the outcome for CAR's unpackaged `.exe` is the same:
33//!   nothing to read, so a WinRT mail backend is not viable. COM/MAPI reaches
34//!   only *classic* Outlook (not new Outlook), needs it installed + running,
35//!   and trips the Outlook programmatic-access security model — not a clean
36//!   parity backend either. So do not re-chase `EmailStore` or MAPI here.
37//! - **Linux** — no local backend (Evolution Data Server would be the candidate).
38//!
39//! Off macOS the backend is therefore **Microsoft Graph** (M365 mail, when
40//! `CAR_MSGRAPH_CLIENT_ID` is set) — the same account CAR already reaches for
41//! Graph contacts/calendar. The genuinely cross-platform *local* answer is an
42//! IMAP + SMTP client keyed off [`car-accounts`](car_accounts) with credentials
43//! from [`car-secrets`](car_secrets); it is not yet wired and, needing
44//! per-account credentials, is not the zero-setup parity Mail.app gives macOS.
45//!
46//! Side-effecting operations (send, draft, delete) should always be
47//! approval-gated at the product layer — this crate exposes them but does
48//! not enforce consent UX.
49
50use base64::engine::general_purpose::URL_SAFE_NO_PAD;
51use base64::Engine as _;
52use chrono::{DateTime, Utc};
53use serde::{Deserialize, Serialize};
54
55use super::{Availability, IntegrationError};
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct MailAccount {
59    pub id: String,
60    pub address: String,
61    pub display_name: Option<String>,
62    pub provider_hint: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct InboxSummary {
67    pub account_id: String,
68    pub unread: u32,
69    pub total: u32,
70    pub most_recent_subject: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct AccountListing {
75    #[serde(flatten)]
76    pub availability: Availability,
77    pub accounts: Vec<MailAccount>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct InboxListing {
82    #[serde(flatten)]
83    pub availability: Availability,
84    pub summaries: Vec<InboxSummary>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SendRequest {
89    pub account_id: String,
90    pub to: Vec<String>,
91    #[serde(default)]
92    pub cc: Vec<String>,
93    #[serde(default)]
94    pub bcc: Vec<String>,
95    pub subject: String,
96    pub body: String,
97    /// When `true`, just save the message as a draft — don't send.
98    #[serde(default)]
99    pub draft_only: bool,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct SendResult {
104    #[serde(flatten)]
105    pub availability: Availability,
106    /// `true` if the message was actually sent or drafted.
107    pub sent: bool,
108    pub message_id: Option<String>,
109}
110
111/// A mailbox (folder) inside one mail account.
112///
113/// `full_name` is the **selector**: pass it back verbatim as
114/// [`MessageQuery::mailbox`] to read that mailbox. On macOS it is the
115/// slash-joined path from the account root (`"Travel"`, `"Travel/2026"`); on
116/// Microsoft Graph it is the folder id. `name` is the leaf label for display.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Mailbox {
119    pub account_id: String,
120    pub name: String,
121    pub full_name: String,
122    pub unread: u32,
123    pub total: u32,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct MailboxListing {
128    #[serde(flatten)]
129    pub availability: Availability,
130    #[serde(default)]
131    pub mailboxes: Vec<Mailbox>,
132}
133
134/// One message row. Header-weight by design — `body` is populated only when
135/// the caller asked for it via [`MessageQuery::include_body`].
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct MessageSummary {
138    /// Opaque, backend-qualified id — feed it straight back to
139    /// [`message_body`]. Callers should not parse it; [`decode_message_id`]
140    /// is the only supported reader.
141    pub id: String,
142    pub account_id: String,
143    /// The mailbox this row came from, as the backend **resolved** it — not
144    /// the selector the caller typed. Same shape as [`Mailbox::full_name`], so
145    /// a row asked for as `"travel"` comes back stamped `"Travel/2026"` and
146    /// can be matched against [`list_mailboxes`] output.
147    pub mailbox: String,
148    pub subject: Option<String>,
149    pub sender: Option<String>,
150    #[serde(default)]
151    pub recipients: Vec<String>,
152    pub date_received: Option<DateTime<Utc>>,
153    pub read: bool,
154    /// Short excerpt. Graph returns `bodyPreview` for free; Mail.app has no
155    /// cheap equivalent, so on macOS this is `None` unless `include_body` was
156    /// set — in which case it is the head of the body.
157    pub preview: Option<String>,
158    pub body: Option<String>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct MessageListing {
163    #[serde(flatten)]
164    pub availability: Availability,
165    pub messages: Vec<MessageSummary>,
166}
167
168/// Which messages to read.
169///
170/// Every field has a default, and the defaults reproduce the pre-existing
171/// INBOX-only behaviour: `mailbox: None` means [`DEFAULT_MAILBOX`], so a
172/// caller that sends `{}` reads exactly what it read before this query
173/// existed.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct MessageQuery {
176    #[serde(default)]
177    pub account_ids: Vec<String>,
178    /// Mailbox selector — a [`Mailbox::full_name`], or a bare leaf name like
179    /// `"Travel"`. `None` reads [`DEFAULT_MAILBOX`].
180    #[serde(default)]
181    pub mailbox: Option<String>,
182    #[serde(default = "default_limit")]
183    pub limit: usize,
184    /// Drop messages received before this instant. Applied *before* `limit`,
185    /// so a narrow window still yields the newest `limit` matches rather than
186    /// whatever survives a pre-filtered slice.
187    #[serde(default)]
188    pub since: Option<DateTime<Utc>>,
189    #[serde(default)]
190    pub include_body: bool,
191}
192
193impl Default for MessageQuery {
194    fn default() -> Self {
195        Self {
196            account_ids: Vec::new(),
197            mailbox: None,
198            limit: default_limit(),
199            since: None,
200            include_body: false,
201        }
202    }
203}
204
205fn default_limit() -> usize {
206    50
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct MessageBodyResult {
211    #[serde(flatten)]
212    pub availability: Availability,
213    /// Echo of the requested id, so a caller fetching several bodies can
214    /// correlate replies without tracking call order.
215    pub id: String,
216    /// `"text"` or `"html"`.
217    pub content_type: String,
218    pub body: Option<String>,
219    /// `true` when the body was cut at [`MESSAGE_BODY_CAP`].
220    pub truncated: bool,
221}
222
223/// The mailbox read when [`MessageQuery::mailbox`] is `None`.
224pub const DEFAULT_MAILBOX: &str = "INBOX";
225
226/// Ceiling on a returned message body, in characters. A body is an unbounded
227/// blob that has to cross a pipe (JXA stdout), the FFI boundary, and a
228/// JSON-RPC frame; cut it once, here, and say so via
229/// [`MessageBodyResult::truncated`] rather than letting one 40 MB newsletter
230/// decide the size of a response.
231pub const MESSAGE_BODY_CAP: usize = 100_000;
232
233const MAILAPP_ID_PREFIX: &str = "mailapp";
234const MSGRAPH_ID_PREFIX: &str = "msgraph";
235
236/// A decoded [`MessageSummary::id`].
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum MessageRef {
239    /// Mail.app. Its numeric message id is only addressable *inside* one
240    /// mailbox of one account, so the account and mailbox have to travel with
241    /// it — which is the whole reason the wire id is composite rather than the
242    /// bare number.
243    MailApp {
244        account_id: String,
245        mailbox: String,
246        local_id: String,
247    },
248    /// Microsoft Graph, whose id is addressable on its own.
249    Graph { id: String },
250}
251
252fn b64(s: &str) -> String {
253    URL_SAFE_NO_PAD.encode(s.as_bytes())
254}
255
256fn unb64(s: &str) -> Option<String> {
257    String::from_utf8(URL_SAFE_NO_PAD.decode(s.as_bytes()).ok()?).ok()
258}
259
260/// Build the Mail.app composite id. The two variable-length components are
261/// base64url'd because account names and mailbox paths carry `:` and `/`
262/// freely, and this string has to survive JXA `argv` and the JSON wire
263/// without an escaping rule of its own.
264pub fn encode_message_id(account_id: &str, mailbox: &str, local_id: &str) -> String {
265    format!(
266        "{MAILAPP_ID_PREFIX}:{}:{}:{local_id}",
267        b64(account_id),
268        b64(mailbox)
269    )
270}
271
272/// Build the Graph id. Graph ids are already opaque and globally unique, so
273/// they only get the backend tag.
274pub fn encode_graph_message_id(graph_id: &str) -> String {
275    format!("{MSGRAPH_ID_PREFIX}:{graph_id}")
276}
277
278/// Inverse of [`encode_message_id`] / [`encode_graph_message_id`].
279pub fn decode_message_id(id: &str) -> Result<MessageRef, IntegrationError> {
280    let bad = || IntegrationError::Backend(format!("not a CAR mail message id: {id}"));
281    let (prefix, rest) = id.split_once(':').ok_or_else(bad)?;
282    match prefix {
283        MSGRAPH_ID_PREFIX => {
284            if rest.is_empty() {
285                return Err(bad());
286            }
287            Ok(MessageRef::Graph {
288                id: rest.to_string(),
289            })
290        }
291        MAILAPP_ID_PREFIX => {
292            // Exactly three fields: neither base64url component can contain a
293            // `:`, and the numeric tail never does either.
294            let mut parts = rest.splitn(3, ':');
295            let account_id = unb64(parts.next().ok_or_else(bad)?).ok_or_else(bad)?;
296            let mailbox = unb64(parts.next().ok_or_else(bad)?).ok_or_else(bad)?;
297            let local_id = parts.next().ok_or_else(bad)?.to_string();
298            if local_id.is_empty() {
299                return Err(bad());
300            }
301            Ok(MessageRef::MailApp {
302                account_id,
303                mailbox,
304                local_id,
305            })
306        }
307        _ => Err(bad()),
308    }
309}
310
311pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
312    backend::list_accounts()
313}
314
315/// Short inbox summary per account. For accounts where the backend can't
316/// reach the server, the summary is omitted.
317pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
318    backend::list_inbox(account_ids)
319}
320
321/// Enumerate every mailbox of the requested accounts (all accounts when the
322/// slice is empty), including nested ones.
323///
324/// This exists because [`list_inbox`] could only ever see INBOX, and any user
325/// with server-side rules has their most useful mail *already filed out of
326/// INBOX* by the time an agent looks — so an INBOX-only scan reports "no
327/// results" where the honest answer is "cannot see there"
328/// (Parslee-ai/car-releases#84).
329pub fn list_mailboxes(account_ids: &[String]) -> Result<MailboxListing, IntegrationError> {
330    backend::list_mailboxes(account_ids)
331}
332
333/// Read message rows from one mailbox per account, newest first.
334///
335/// "Newest first" is global, not per account: rows from every matched account
336/// are merged into one date-ordered list before [`MessageQuery::limit`] is
337/// applied, so `limit: 1` over two accounts returns the newer of the two
338/// messages rather than whichever account Mail happened to list first.
339///
340/// Rows carry a stable [`MessageSummary::id`] that [`message_body`] accepts.
341pub fn list_messages(query: MessageQuery) -> Result<MessageListing, IntegrationError> {
342    backend::list_messages(query)
343}
344
345/// Fetch one message's body by the id from [`list_messages`].
346pub fn message_body(message_id: &str) -> Result<MessageBodyResult, IntegrationError> {
347    backend::message_body(message_id)
348}
349
350/// Send or draft a message through the active platform mail backend.
351pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
352    backend::send(req)
353}
354
355#[cfg(target_os = "macos")]
356mod backend {
357    use super::*;
358
359    // `pub(super)` only so the tests below can run this exact script against a
360    // stubbed `mailApp()`. Without that the whole mailbox walk, selector
361    // resolution, and sort/since/limit pipeline is an untested JS string —
362    // which is how the multi-account ordering bug got in.
363    pub(super) const JXA: &str = r#"
364function normalizeAccount(account) {
365  let name = "";
366  let id = "";
367  let addresses = [];
368  try { name = String(account.name()); } catch (e) {}
369  try { id = String(account.id()); } catch (e) {}
370  if (!id) id = name;
371  try { addresses = account.emailAddresses().map(String); } catch (e) {}
372  return {
373    id: id,
374    address: addresses[0] || name,
375    display_name: name || null,
376    provider_hint: null
377  };
378}
379
380function accountMatches(account, requested) {
381  if (requested.length === 0) return true;
382  const normalized = normalizeAccount(account);
383  return requested.indexOf(normalized.id) >= 0 || requested.indexOf(normalized.address) >= 0 || requested.indexOf(normalized.display_name || "") >= 0;
384}
385
386function mailApp() {
387  const app = Application("/System/Applications/Mail.app");
388  app.includeStandardAdditions = true;
389  return app;
390}
391
392// A `byName` specifier is lazy — it resolves (or throws) only when a property
393// is read off it, so "did I get a real mailbox" needs an actual touch.
394function mailboxUsable(box) {
395  try { box.name(); return true; } catch (e) { return false; }
396}
397
398// Depth-first walk of every mailbox under `container` (an account or a
399// mailbox), yielding {name, full_name, box}. Counts are deliberately NOT read
400// here: resolution doesn't need them, and reading them is what makes an
401// enumeration expensive.
402function walkMailboxes(container, prefix, out, depth) {
403  let boxes = [];
404  try { boxes = container.mailboxes(); } catch (e) { return; }
405  for (let i = 0; i < boxes.length; i++) {
406    const box = boxes[i];
407    let name = "";
408    try { name = String(box.name()); } catch (e) { continue; }
409    const full = prefix ? prefix + "/" + name : name;
410    out.push({name: name, full_name: full, box: box});
411    if (depth < 8) walkMailboxes(box, full, out, depth + 1);
412  }
413}
414
415// Resolve a mailbox selector: the fast `byName` path first (which is what
416// INBOX always hits), then a full-path match, then a leaf-name match. The last
417// two are case-insensitive because mailbox names are localized and users type
418// "travel".
419//
420// Returns `{box, full_name}`, not the bare box: the RESOLVED path has to
421// travel with the mailbox so a row can report where it actually came from
422// instead of echoing whatever selector the caller typed. A caller that asked
423// for "travel" and got rows stamped "travel" cannot match them against
424// `mail.mailboxes` output; stamped "Travel/2026" it can.
425function resolveMailbox(account, wanted) {
426  const target = String(wanted || "INBOX");
427  let direct = null;
428  try { direct = account.mailboxes.byName(target); } catch (e) {}
429  if (direct && mailboxUsable(direct)) {
430    // `byName` only ever reaches a top-level mailbox, so its own name IS the
431    // full path — read it back so the casing is Mail's, not the caller's.
432    let name = target;
433    try { name = String(direct.name()); } catch (e) {}
434    return {box: direct, full_name: name};
435  }
436  const all = [];
437  walkMailboxes(account, "", all, 0);
438  const lower = target.toLowerCase();
439  for (let i = 0; i < all.length; i++) {
440    if (all[i].full_name.toLowerCase() === lower) return {box: all[i].box, full_name: all[i].full_name};
441  }
442  for (let i = 0; i < all.length; i++) {
443    if (all[i].name.toLowerCase() === lower) return {box: all[i].box, full_name: all[i].full_name};
444  }
445  return null;
446}
447
448// Newest first. `date_received` is an ISO-8601 `Z` string, so a plain string
449// compare IS the chronological compare; rows with no date sort to the end
450// rather than to the front.
451function byDateDesc(a, b) {
452  const x = String(a.date_received || "");
453  const y = String(b.date_received || "");
454  if (x === y) return 0;
455  return x < y ? 1 : -1;
456}
457
458// "No account matched what you asked for" must not read as "you have no mail".
459function unmatchedAccounts(requested) {
460  return "no mail account matched " + requested.join(", ") + " — list them with mail.accounts";
461}
462
463function truncateBody(text, cap) {
464  const s = String(text);
465  if (cap > 0 && s.length > cap) return {body: s.slice(0, cap), truncated: true};
466  return {body: s, truncated: false};
467}
468
469function run(argv) {
470  const mode = argv[0] || "accounts";
471  let Mail;
472  try {
473    Mail = mailApp();
474  } catch (e) {
475    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});
476  }
477
478  if (mode === "accounts") {
479    try {
480      return JSON.stringify({available:true, backend:"mail_app", reason:null, accounts: Mail.accounts().map(normalizeAccount)});
481    } catch (e) {
482      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), accounts:[]});
483    }
484  }
485
486  if (mode === "inbox") {
487    const requested = argv.slice(1);
488    const summaries = [];
489    try {
490      Mail.accounts().forEach(account => {
491        if (!accountMatches(account, requested)) return;
492        const normalized = normalizeAccount(account);
493        let unread = 0;
494        let total = 0;
495        let subject = null;
496        try {
497          const inbox = account.mailboxes.byName("INBOX");
498          const messages = inbox.messages();
499          total = messages.length;
500          for (let i = 0; i < messages.length; i++) {
501            const message = messages[i];
502            try { if (message.readStatus() === false) unread += 1; } catch (e) {}
503            if (subject === null) {
504              try { subject = String(message.subject()); } catch (e) {}
505            }
506          }
507        } catch (e) {}
508        summaries.push({account_id: normalized.id, unread: unread, total: total, most_recent_subject: subject});
509      });
510      return JSON.stringify({available:true, backend:"mail_app", reason:null, summaries:summaries});
511    } catch (e) {
512      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), summaries:[]});
513    }
514  }
515
516  if (mode === "mailboxes") {
517    const requested = argv.slice(1);
518    const mailboxes = [];
519    let matchedAccounts = 0;
520    try {
521      Mail.accounts().forEach(account => {
522        if (!accountMatches(account, requested)) return;
523        matchedAccounts += 1;
524        const normalized = normalizeAccount(account);
525        const all = [];
526        walkMailboxes(account, "", all, 0);
527        for (let i = 0; i < all.length; i++) {
528          let unread = 0;
529          let total = 0;
530          try { unread = Number(all[i].box.unreadCount()); } catch (e) {}
531          // `.length` on the element specifier is a `count` Apple Event — one
532          // round trip. Calling `messages()` first would materialize every
533          // specifier in the mailbox just to count them.
534          try { total = Number(all[i].box.messages.length); } catch (e) {}
535          mailboxes.push({
536            account_id: normalized.id,
537            name: all[i].name,
538            full_name: all[i].full_name,
539            unread: isFinite(unread) ? unread : 0,
540            total: isFinite(total) ? total : 0
541          });
542        }
543      });
544      // An `account_ids` filter that matched nothing is a caller error, not an
545      // account with no folders — say so instead of returning an empty list.
546      if (matchedAccounts === 0 && requested.length > 0) {
547        return JSON.stringify({available:false, backend:"mail_app", reason:unmatchedAccounts(requested), mailboxes:[]});
548      }
549      return JSON.stringify({available:true, backend:"mail_app", reason:null, mailboxes:mailboxes});
550    } catch (e) {
551      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), mailboxes:[]});
552    }
553  }
554
555  if (mode === "messages") {
556    try {
557      const q = JSON.parse(argv[1] || "{}");
558      const requested = q.account_ids || [];
559      const wanted = q.mailbox || "INBOX";
560      const limit = q.limit > 0 ? q.limit : 50;
561      const since = q.since ? String(q.since) : null;
562      const includeBody = q.include_body === true;
563      const bodyCap = q.body_cap > 0 ? q.body_cap : 100000;
564      // Every matched account contributes into ONE candidate array, which is
565      // sorted and sliced ONCE at the end. Sorting and slicing per account and
566      // concatenating makes the answer depend on account order: with an iCloud
567      // and an Exchange account both holding a "Travel" mailbox, `limit: 1`
568      // returns iCloud's newest message even when Exchange holds a newer one,
569      // and `limit: 10` returns rows that are not in date order at all. That is
570      // the same silent miss this surface exists to end, one level down
571      // (Parslee-ai/car-releases#84) — and "newest first" is a documented
572      // contract, not a best effort.
573      const candidates = [];
574      let matchedAccounts = 0;
575      let resolvedMailbox = false;
576      Mail.accounts().forEach(account => {
577        if (!accountMatches(account, requested)) return;
578        matchedAccounts += 1;
579        const normalized = normalizeAccount(account);
580        const hit = resolveMailbox(account, wanted);
581        if (!hit) return;
582        resolvedMailbox = true;
583        const msgs = hit.box.messages;
584
585        // Bulk array property gets: five Apple Events for the WHOLE mailbox,
586        // whatever its size. The pre-existing inbox walk above reads each
587        // property off each message individually, which is one Apple Event per
588        // message per field — that is why a full mailbox scan flirts with the
589        // 15s host timeout, and it is the thing not to repeat here.
590        let ids = null, subjects = null, senders = null, dates = null, reads = null;
591        try { ids = msgs.id(); } catch (e) {}
592        try { subjects = msgs.subject(); } catch (e) {}
593        try { senders = msgs.sender(); } catch (e) {}
594        try { dates = msgs.dateReceived(); } catch (e) {}
595        try { reads = msgs.readStatus(); } catch (e) {}
596
597        const items = [];
598        if (ids !== null) {
599          for (let i = 0; i < ids.length; i++) {
600            let iso = null;
601            try { if (dates && dates[i]) iso = new Date(dates[i]).toISOString(); } catch (e) {}
602            items.push({
603              msgs: msgs,
604              account_id: normalized.id,
605              mailbox: hit.full_name,
606              index: i,
607              local_id: String(ids[i]),
608              subject: subjects && subjects[i] != null ? String(subjects[i]) : null,
609              sender: senders && senders[i] != null ? String(senders[i]) : null,
610              date_received: iso,
611              read: reads ? reads[i] === true : false
612            });
613          }
614        } else {
615          // Fallback when a bulk get throws (some IMAP accounts refuse them):
616          // per-message reads, but bounded — never a whole-mailbox loop.
617          let count = 0;
618          try { count = Number(msgs.length); } catch (e) {}
619          const scan = Math.min(count, limit * 4);
620          for (let i = 0; i < scan; i++) {
621            try {
622              const m = msgs[i];
623              let iso = null;
624              try { iso = new Date(m.dateReceived()).toISOString(); } catch (e) {}
625              items.push({
626                msgs: msgs,
627                account_id: normalized.id,
628                mailbox: hit.full_name,
629                index: i,
630                local_id: String(m.id()),
631                subject: (function(){ try { return String(m.subject()); } catch (e) { return null; } })(),
632                sender: (function(){ try { return String(m.sender()); } catch (e) { return null; } })(),
633                date_received: iso,
634                read: (function(){ try { return m.readStatus() === true; } catch (e) { return false; } })()
635              });
636            } catch (e) {}
637          }
638        }
639
640        // Per-account: newest first, then `since`, then at most `limit`
641        // forwarded to the combined pool. Filtering before the slice is what
642        // makes a narrow `since` window return real matches instead of
643        // whatever happened to land in the first `limit` rows. Capping at
644        // `limit` here is lossless for the global answer — `since` keeps a
645        // PREFIX of a newest-first list, so an account can never own a
646        // globally-selected row from beyond its own newest `limit` — and it
647        // keeps one 200k-message mailbox from dominating the combined sort.
648        items.sort(byDateDesc);
649        let taken = 0;
650        for (let i = 0; i < items.length && taken < limit; i++) {
651          if (since && (!items[i].date_received || items[i].date_received < since)) continue;
652          candidates.push(items[i]);
653          taken += 1;
654        }
655      });
656      // "No account matched what you asked for" and "that mailbox does not
657      // exist here" must NOT look like "that mailbox is empty" — the
658      // silent-empty answer is the whole failure this surface exists to end
659      // (Parslee-ai/car-releases#84).
660      if (matchedAccounts === 0 && requested.length > 0) {
661        return JSON.stringify({available:false, backend:"mail_app", reason:unmatchedAccounts(requested), messages:[]});
662      }
663      if (matchedAccounts > 0 && !resolvedMailbox) {
664        return JSON.stringify({available:false, backend:"mail_app", reason:"no mailbox named "+wanted+" in the selected account(s) — list them with mail.mailboxes", messages:[]});
665      }
666      // The ONE global sort. Only the rows that survive it pay for the per-row
667      // Apple Events below, so a multi-account read costs no more round trips
668      // than a single-account one did.
669      candidates.sort(byDateDesc);
670      const rows = [];
671      for (let i = 0; i < candidates.length && rows.length < limit; i++) {
672        const item = candidates[i];
673        const row = {
674          account_id: item.account_id,
675          mailbox: item.mailbox,
676          local_id: item.local_id,
677          subject: item.subject,
678          sender: item.sender,
679          recipients: [],
680          date_received: item.date_received,
681          read: item.read,
682          preview: null,
683          body: null
684        };
685        // Per-row reads, bounded by `limit` rather than by mailbox size.
686        try { row.recipients = item.msgs[item.index].toRecipients.address().map(String); } catch (e) {}
687        if (includeBody) {
688          try {
689            const cut = truncateBody(item.msgs[item.index].content(), bodyCap);
690            row.body = cut.body;
691            row.preview = cut.body.slice(0, 200);
692          } catch (e) {}
693        }
694        rows.push(row);
695      }
696      return JSON.stringify({available:true, backend:"mail_app", reason:null, messages:rows});
697    } catch (e) {
698      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), messages:[]});
699    }
700  }
701
702  if (mode === "body") {
703    const accountId = String(argv[1] || "");
704    const wanted = String(argv[2] || "INBOX");
705    const localId = String(argv[3] || "");
706    const bodyCap = Number(argv[4] || 100000);
707    try {
708      let found = null;
709      const accounts = Mail.accounts();
710      for (let a = 0; a < accounts.length && !found; a++) {
711        if (!accountMatches(accounts[a], [accountId])) continue;
712        const hit = resolveMailbox(accounts[a], wanted);
713        if (!hit) continue;
714        const box = hit.box;
715        const numeric = parseInt(localId, 10);
716        if (isFinite(numeric)) {
717          try {
718            const hits = box.messages.whose({id: numeric})();
719            if (hits.length > 0) found = hits[0];
720          } catch (e) {}
721        }
722        if (!found) {
723          // `whose` is unsupported on some account types; fall back to one
724          // bulk id fetch plus an index lookup, not a per-message probe.
725          try {
726            const ids = box.messages.id();
727            for (let i = 0; i < ids.length; i++) {
728              if (String(ids[i]) === localId) { found = box.messages[i]; break; }
729            }
730          } catch (e) {}
731        }
732      }
733      if (!found) {
734        return JSON.stringify({available:false, backend:"mail_app", reason:"message "+localId+" not found in mailbox "+wanted, content_type:"text", body:null, truncated:false});
735      }
736      let raw = null;
737      try { raw = found.content(); } catch (e) {
738        return JSON.stringify({available:false, backend:"mail_app", reason:String(e), content_type:"text", body:null, truncated:false});
739      }
740      const cut = truncateBody(raw === null ? "" : raw, bodyCap);
741      return JSON.stringify({available:true, backend:"mail_app", reason:null, content_type:"text", body:cut.body, truncated:cut.truncated});
742    } catch (e) {
743      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), content_type:"text", body:null, truncated:false});
744    }
745  }
746
747  if (mode === "send") {
748    try {
749      const req = JSON.parse(argv[1] || "{}");
750      const msg = Mail.OutgoingMessage({
751        subject: req.subject || "",
752        content: req.body || "",
753        visible: false
754      });
755      Mail.outgoingMessages.push(msg);
756      (req.to || []).forEach(address => msg.toRecipients.push(Mail.Recipient({address: String(address)})));
757      (req.cc || []).forEach(address => msg.ccRecipients.push(Mail.Recipient({address: String(address)})));
758      (req.bcc || []).forEach(address => msg.bccRecipients.push(Mail.Recipient({address: String(address)})));
759      if (req.account_id) {
760        const accounts = Mail.accounts();
761        let matched = null;
762        for (let i = 0; i < accounts.length; i++) {
763          const normalized = normalizeAccount(accounts[i]);
764          if (normalized.id === req.account_id || normalized.address === req.account_id || normalized.display_name === req.account_id) {
765            matched = normalized;
766            break;
767          }
768        }
769        // A specified-but-unresolvable account must NOT silently fall
770        // through to Mail's default outgoing account (car-releases#47).
771        if (!matched) {
772          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});
773        }
774        // The JXA `sender` setter throws under some Mail/account-type
775        // combos (EWS vs IMAP) and can also no-op without throwing.
776        // Set it, then read it back and confirm the address actually
777        // took — never assume success.
778        let setError = null;
779        try { msg.sender(matched.address); } catch (e) { setError = String(e); }
780        let effective = null;
781        try { effective = String(msg.sender()); } catch (e) {}
782        const wanted = String(matched.address || "").toLowerCase();
783        const took = wanted.length > 0 && effective !== null &&
784                     String(effective).toLowerCase().indexOf(wanted) !== -1;
785        if (!took) {
786          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});
787        }
788      }
789      if (req.draft_only) {
790        msg.save();
791      } else {
792        msg.send();
793      }
794      let messageId = null;
795      try { messageId = String(msg.id()); } catch (e) {}
796      return JSON.stringify({available:true, backend:"mail_app", reason:null, sent:true, message_id:messageId});
797    } catch (e) {
798      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), sent:false, message_id:null});
799    }
800  }
801
802  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});
803}
804"#;
805
806    pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
807        let mail_listing: AccountListing = run_jxa(&["accounts"])?;
808        if mail_listing.availability.available || !mail_listing.accounts.is_empty() {
809            return Ok(mail_listing);
810        }
811
812        let accounts = car_accounts::list()
813            .map_err(|e| IntegrationError::Backend(format!("accounts fallback: {e}")))?
814            .accounts
815            .into_iter()
816            .filter(|account| account.capabilities.iter().any(|cap| cap == "mail"))
817            .map(|account| MailAccount {
818                id: account.id,
819                address: account.identifier.unwrap_or(account.label.clone()),
820                display_name: Some(account.label),
821                provider_hint: Some(account.provider),
822            })
823            .collect();
824
825        Ok(AccountListing {
826            availability: Availability::available("internet_accounts"),
827            accounts,
828        })
829    }
830
831    pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
832        let mut args = vec!["inbox"];
833        args.extend(account_ids.iter().map(String::as_str));
834        run_jxa(&args)
835    }
836
837    pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
838        let req_json = serde_json::to_string(&req)
839            .map_err(|e| IntegrationError::Backend(format!("mail request json: {e}")))?;
840        run_jxa(&["send", req_json.as_str()])
841    }
842
843    pub fn list_mailboxes(account_ids: &[String]) -> Result<MailboxListing, IntegrationError> {
844        let mut args = vec!["mailboxes"];
845        args.extend(account_ids.iter().map(String::as_str));
846        run_jxa(&args)
847    }
848
849    /// A row as JXA emits it: everything but the composite id, which JXA
850    /// cannot build (no base64 in the JXA global scope) and which is Rust's
851    /// job anyway — one encoder, one decoder, both unit-tested.
852    #[derive(Deserialize)]
853    struct RawMessage {
854        #[serde(default)]
855        account_id: String,
856        #[serde(default)]
857        mailbox: String,
858        #[serde(default)]
859        local_id: String,
860        subject: Option<String>,
861        sender: Option<String>,
862        #[serde(default)]
863        recipients: Vec<String>,
864        date_received: Option<DateTime<Utc>>,
865        #[serde(default)]
866        read: bool,
867        preview: Option<String>,
868        body: Option<String>,
869    }
870
871    #[derive(Deserialize)]
872    struct RawMessageListing {
873        #[serde(flatten)]
874        availability: Availability,
875        #[serde(default)]
876        messages: Vec<RawMessage>,
877    }
878
879    #[derive(Deserialize)]
880    struct RawBodyResult {
881        #[serde(flatten)]
882        availability: Availability,
883        #[serde(default)]
884        content_type: String,
885        body: Option<String>,
886        #[serde(default)]
887        truncated: bool,
888    }
889
890    pub fn list_messages(query: MessageQuery) -> Result<MessageListing, IntegrationError> {
891        let mailbox = query
892            .mailbox
893            .clone()
894            .unwrap_or_else(|| DEFAULT_MAILBOX.to_string());
895        let payload = serde_json::json!({
896            "account_ids": query.account_ids,
897            "mailbox": mailbox,
898            "limit": query.limit.clamp(1, 500),
899            // JXA compares `since` against `Date.toISOString()` as a STRING,
900            // so both sides must be the same shape. `to_rfc3339()` renders the
901            // offset as `+00:00`, which sorts before `.` and would make the
902            // comparison depend on formatting rather than on time — emit the
903            // millisecond-precision `Z` form `toISOString()` produces.
904            "since": query.since.map(|t| t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()),
905            "include_body": query.include_body,
906            "body_cap": MESSAGE_BODY_CAP,
907        })
908        .to_string();
909        let raw: RawMessageListing = run_jxa(&["messages", payload.as_str()])?;
910        Ok(MessageListing {
911            availability: raw.availability,
912            messages: raw
913                .messages
914                .into_iter()
915                .map(|m| MessageSummary {
916                    id: encode_message_id(&m.account_id, &m.mailbox, &m.local_id),
917                    account_id: m.account_id,
918                    mailbox: m.mailbox,
919                    subject: m.subject,
920                    sender: m.sender,
921                    recipients: m.recipients,
922                    date_received: m.date_received,
923                    read: m.read,
924                    preview: m.preview,
925                    body: m.body,
926                })
927                .collect(),
928        })
929    }
930
931    pub fn message_body(message_id: &str) -> Result<MessageBodyResult, IntegrationError> {
932        let (account_id, mailbox, local_id) = match decode_message_id(message_id)? {
933            MessageRef::MailApp {
934                account_id,
935                mailbox,
936                local_id,
937            } => (account_id, mailbox, local_id),
938            MessageRef::Graph { .. } => {
939                return Err(IntegrationError::Backend(format!(
940                    "message id {message_id} belongs to the Microsoft Graph backend, \
941                     not to Mail.app"
942                )))
943            }
944        };
945        let cap = MESSAGE_BODY_CAP.to_string();
946        let raw: RawBodyResult = run_jxa(&[
947            "body",
948            account_id.as_str(),
949            mailbox.as_str(),
950            local_id.as_str(),
951            cap.as_str(),
952        ])?;
953        Ok(MessageBodyResult {
954            availability: raw.availability,
955            id: message_id.to_string(),
956            content_type: if raw.content_type.is_empty() {
957                "text".to_string()
958            } else {
959                raw.content_type
960            },
961            body: raw.body,
962            truncated: raw.truncated,
963        })
964    }
965
966    /// Thin wrapper over [`crate::jxa::run`]. This used to be its own copy of
967    /// the spawn/write/wait dance with a plain blocking `wait_with_output()`,
968    /// so when Mail did not answer an Apple Event the call sat on Apple's ~120s
969    /// default: `mail.accounts` took a measured 121.0s to return — and then
970    /// reported `available: true` with an empty account list, a "success" that
971    /// cost two minutes (Parslee-ai/car#618).
972    fn run_jxa<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
973        crate::jxa::run(JXA, args, crate::jxa::DEFAULT_TIMEOUT)
974    }
975}
976
977#[cfg(not(target_os = "macos"))]
978mod backend {
979    use super::*;
980
981    pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
982        if crate::msgraph::is_configured() {
983            return Ok(AccountListing {
984                availability: Availability::available("msgraph"),
985                accounts: vec![MailAccount {
986                    id: "msgraph".into(),
987                    address: String::new(),
988                    display_name: Some("Microsoft 365".into()),
989                    provider_hint: Some("microsoft".into()),
990                }],
991            });
992        }
993        Ok(AccountListing {
994            availability: current_backend_pending(),
995            accounts: vec![],
996        })
997    }
998
999    pub fn list_inbox(_account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
1000        // Microsoft Graph backend (car#520) when configured; else pending.
1001        if crate::msgraph::is_configured() {
1002            return Ok(match crate::msgraph::inbox_summary("msgraph") {
1003                Ok(summary) => InboxListing {
1004                    availability: Availability::available("msgraph"),
1005                    summaries: vec![summary],
1006                },
1007                Err(e) => InboxListing {
1008                    availability: Availability::pending("msgraph", e.to_string()),
1009                    summaries: vec![],
1010                },
1011            });
1012        }
1013        Ok(InboxListing {
1014            availability: current_backend_pending(),
1015            summaries: vec![],
1016        })
1017    }
1018
1019    pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
1020        // Microsoft Graph send/draft (car#531) when configured; else pending.
1021        if crate::msgraph::is_configured() {
1022            return Ok(match crate::msgraph::send_mail(&req) {
1023                Ok(message_id) => SendResult {
1024                    availability: Availability::available("msgraph"),
1025                    sent: true,
1026                    message_id,
1027                },
1028                Err(e) => SendResult {
1029                    availability: Availability::pending("msgraph", e.to_string()),
1030                    sent: false,
1031                    message_id: None,
1032                },
1033            });
1034        }
1035        Ok(SendResult {
1036            availability: current_backend_pending(),
1037            sent: false,
1038            message_id: None,
1039        })
1040    }
1041
1042    pub fn list_mailboxes(_account_ids: &[String]) -> Result<MailboxListing, IntegrationError> {
1043        if crate::msgraph::is_configured() {
1044            return Ok(match crate::msgraph::mail_folders("msgraph") {
1045                Ok(mailboxes) => MailboxListing {
1046                    availability: Availability::available("msgraph"),
1047                    mailboxes,
1048                },
1049                Err(e) => MailboxListing {
1050                    availability: Availability::pending("msgraph", e.to_string()),
1051                    mailboxes: vec![],
1052                },
1053            });
1054        }
1055        Ok(MailboxListing {
1056            availability: current_backend_pending(),
1057            mailboxes: vec![],
1058        })
1059    }
1060
1061    pub fn list_messages(query: MessageQuery) -> Result<MessageListing, IntegrationError> {
1062        if crate::msgraph::is_configured() {
1063            return Ok(match crate::msgraph::messages("msgraph", &query) {
1064                Ok(messages) => MessageListing {
1065                    availability: Availability::available("msgraph"),
1066                    messages,
1067                },
1068                Err(e) => MessageListing {
1069                    availability: Availability::pending("msgraph", e.to_string()),
1070                    messages: vec![],
1071                },
1072            });
1073        }
1074        Ok(MessageListing {
1075            availability: current_backend_pending(),
1076            messages: vec![],
1077        })
1078    }
1079
1080    pub fn message_body(message_id: &str) -> Result<MessageBodyResult, IntegrationError> {
1081        let graph_id = match decode_message_id(message_id)? {
1082            MessageRef::Graph { id } => id,
1083            MessageRef::MailApp { .. } => {
1084                return Err(IntegrationError::Backend(format!(
1085                    "message id {message_id} belongs to the macOS Mail.app backend, \
1086                     which is not available on this platform"
1087                )))
1088            }
1089        };
1090        if crate::msgraph::is_configured() {
1091            return Ok(match crate::msgraph::message_body(&graph_id) {
1092                Ok(mut r) => {
1093                    r.id = message_id.to_string();
1094                    r
1095                }
1096                Err(e) => MessageBodyResult {
1097                    availability: Availability::pending("msgraph", e.to_string()),
1098                    id: message_id.to_string(),
1099                    content_type: "text".into(),
1100                    body: None,
1101                    truncated: false,
1102                },
1103            });
1104        }
1105        Ok(MessageBodyResult {
1106            availability: current_backend_pending(),
1107            id: message_id.to_string(),
1108            content_type: "text".into(),
1109            body: None,
1110            truncated: false,
1111        })
1112    }
1113
1114    fn current_backend_pending() -> Availability {
1115        Availability::pending(
1116            "imap_smtp",
1117            "Set CAR_MSGRAPH_CLIENT_ID (Azure AD app) to enable the Microsoft \
1118             Graph mail backend (car#520/#531 — inbox + send/draft; \
1119             car-releases#84 — mailboxes, message rows, bodies); a local \
1120             IMAP/SMTP backend is not yet wired.",
1121        )
1122    }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127    use super::*;
1128
1129    // The composite id is the one piece of this feature that has to survive
1130    // two hostile hops — JXA `argv` and the JSON wire — so it is checked
1131    // against the characters that actually break naive delimiters.
1132    #[test]
1133    fn message_id_round_trips_through_awkward_names() {
1134        for (account, mailbox, local) in [
1135            ("iCloud", "INBOX", "12345"),
1136            ("Exchange: work", "Travel/2026 — flights", "7"),
1137            ("a:b:c", "x:y:z", "0"),
1138            ("", "", "1"),
1139            ("Ünïcode ✈", "Ordner/Reisen", "99999999"),
1140        ] {
1141            let encoded = encode_message_id(account, mailbox, local);
1142            assert!(
1143                !encoded[MAILAPP_ID_PREFIX.len() + 1..].contains(' '),
1144                "encoded id must be argv-safe: {encoded}"
1145            );
1146            assert_eq!(
1147                decode_message_id(&encoded).unwrap(),
1148                MessageRef::MailApp {
1149                    account_id: account.to_string(),
1150                    mailbox: mailbox.to_string(),
1151                    local_id: local.to_string(),
1152                }
1153            );
1154        }
1155    }
1156
1157    #[test]
1158    fn graph_message_id_round_trips() {
1159        // Graph ids carry `=`, `-` and `_`; none of them may be mangled.
1160        let raw = "AAMkAGI2T=Gt-Zg_AAA";
1161        assert_eq!(
1162            decode_message_id(&encode_graph_message_id(raw)).unwrap(),
1163            MessageRef::Graph {
1164                id: raw.to_string()
1165            }
1166        );
1167    }
1168
1169    #[test]
1170    fn decode_rejects_ids_it_did_not_mint() {
1171        for bad in [
1172            "",
1173            "12345",
1174            "mailapp",
1175            "mailapp:only-two:parts",
1176            "mailapp:aQ:aQ:",
1177            "mailapp:!!!:aQ:1",
1178            "msgraph:",
1179            "imap:aQ:aQ:1",
1180        ] {
1181            assert!(
1182                decode_message_id(bad).is_err(),
1183                "expected {bad:?} to be rejected"
1184            );
1185        }
1186    }
1187
1188    // `{}` has to keep meaning "the INBOX, newest 50" — that is the promise
1189    // that every pre-existing caller of the mail read surface relies on.
1190    #[test]
1191    fn message_query_defaults_to_inbox_and_fifty() {
1192        let q: MessageQuery = serde_json::from_str("{}").unwrap();
1193        assert!(q.mailbox.is_none());
1194        assert_eq!(q.limit, 50);
1195        assert!(q.account_ids.is_empty());
1196        assert!(q.since.is_none());
1197        assert!(!q.include_body);
1198        assert_eq!(
1199            q.mailbox.unwrap_or_else(|| DEFAULT_MAILBOX.to_string()),
1200            "INBOX"
1201        );
1202    }
1203
1204    #[test]
1205    fn message_query_parses_a_full_payload() {
1206        let q: MessageQuery = serde_json::from_str(
1207            r#"{"account_ids":["work"],"mailbox":"Travel","limit":5,
1208                "since":"2026-01-01T00:00:00Z","include_body":true}"#,
1209        )
1210        .unwrap();
1211        assert_eq!(q.account_ids, vec!["work".to_string()]);
1212        assert_eq!(q.mailbox.as_deref(), Some("Travel"));
1213        assert_eq!(q.limit, 5);
1214        assert_eq!(q.since.unwrap().to_rfc3339(), "2026-01-01T00:00:00+00:00");
1215        assert!(q.include_body);
1216    }
1217
1218    // --- the JXA itself, against a stubbed Mail.app ------------------------
1219    //
1220    // Everything that actually closes Parslee-ai/car-releases#84 on macOS —
1221    // the mailbox walk, selector resolution, the sort/since/limit pipeline —
1222    // lives in a JS string that no other test reaches. That is how the
1223    // multi-account ordering bug got in: each account was sorted and sliced
1224    // inside `Mail.accounts().forEach`, so the concatenation was neither
1225    // globally newest-first nor limit-fair.
1226    //
1227    // The script IS testable without Mail.app. Appending a `mailApp = ...`
1228    // assignment after the script body overrides the hoisted declaration, so
1229    // `run` drives the real logic over a fake object graph — no Apple Events,
1230    // no TCC prompt, nothing that depends on the machine's mail setup.
1231    #[cfg(target_os = "macos")]
1232    mod jxa {
1233        use super::*;
1234
1235        /// Two accounts that both hold a `Travel` mailbox, with the newest
1236        /// message overall in the SECOND one — the reporter's configuration
1237        /// (iCloud + Exchange, rules filing mail out of INBOX). Account one
1238        /// also nests `Travel/2026`, to pin path- and leaf-resolution.
1239        const MOCK: &str = r#"
1240function mkMessages(rows) {
1241  const api = function (i) { return api[i]; };
1242  rows.forEach(function (r, i) {
1243    api[i] = {
1244      id: function () { return r.id; },
1245      subject: function () { return r.subject; },
1246      sender: function () { return r.sender; },
1247      dateReceived: function () { return r.date; },
1248      readStatus: function () { return r.read === true; },
1249      content: function () { return "body of " + r.id; },
1250      toRecipients: { address: function () { return ["me@example.com"]; } }
1251    };
1252  });
1253  api.length = rows.length;
1254  api.id = function () { return rows.map(function (r) { return r.id; }); };
1255  api.subject = function () { return rows.map(function (r) { return r.subject; }); };
1256  api.sender = function () { return rows.map(function (r) { return r.sender; }); };
1257  api.dateReceived = function () { return rows.map(function (r) { return r.date; }); };
1258  api.readStatus = function () { return rows.map(function (r) { return r.read === true; }); };
1259  api.whose = function () { return function () { return []; }; };
1260  return api;
1261}
1262function mkBox(name, rows, children) {
1263  const kids = children || [];
1264  return {
1265    name: function () { return name; },
1266    unreadCount: function () { return 0; },
1267    messages: mkMessages(rows),
1268    mailboxes: function () { return kids; }
1269  };
1270}
1271function mkAccount(id, address, boxes) {
1272  const list = function () { return boxes; };
1273  list.byName = function (n) {
1274    for (let i = 0; i < boxes.length; i++) if (boxes[i].name() === n) return boxes[i];
1275    throw new Error("no mailbox " + n);
1276  };
1277  return {
1278    id: function () { return id; },
1279    name: function () { return address; },
1280    emailAddresses: function () { return [address]; },
1281    mailboxes: list
1282  };
1283}
1284const ACC1 = mkAccount("ACC-1", "one@example.com", [
1285  mkBox("INBOX", [{id: 11, subject: "inbox one", sender: "a@x", date: "2026-03-03T00:00:00Z"}]),
1286  mkBox("Travel", [
1287    {id: 101, subject: "acc1 aug", sender: "air@x", date: "2026-08-01T00:00:00Z"},
1288    {id: 102, subject: "acc1 jan", sender: "air@x", date: "2026-01-01T00:00:00Z"}
1289  ], [
1290    mkBox("2026", [{id: 103, subject: "nested", sender: "air@x", date: "2026-05-05T00:00:00Z"}])
1291  ])
1292]);
1293const ACC2 = mkAccount("ACC-2", "two@example.com", [
1294  mkBox("INBOX", [{id: 21, subject: "inbox two", sender: "b@x", date: "2026-04-04T00:00:00Z"}]),
1295  mkBox("Travel", [{id: 201, subject: "acc2 newest", sender: "air@x", date: "2026-08-20T00:00:00Z"}])
1296]);
1297const MOCK_APP = {accounts: function () { return [ACC1, ACC2]; }, includeStandardAdditions: false};
1298mailApp = function () { return MOCK_APP; };
1299"#;
1300
1301        fn run(args: &[&str]) -> serde_json::Value {
1302            let script = format!("{}\n{MOCK}", super::super::backend::JXA);
1303            let out = crate::jxa::run_raw(&script, args, crate::jxa::DEFAULT_TIMEOUT)
1304                .expect("osascript should run the stubbed script");
1305            serde_json::from_slice(&out).expect("stubbed script should emit JSON")
1306        }
1307
1308        fn messages(query: serde_json::Value) -> serde_json::Value {
1309            run(&["messages", &query.to_string()])
1310        }
1311
1312        fn dates(v: &serde_json::Value) -> Vec<String> {
1313            v["messages"]
1314                .as_array()
1315                .unwrap()
1316                .iter()
1317                .map(|m| m["date_received"].as_str().unwrap_or("").to_string())
1318                .collect()
1319        }
1320
1321        // THE regression. Sorting and slicing per account and concatenating
1322        // returns ACC-1's 2026-08-01 message here and drops ACC-2's
1323        // 2026-08-20 one, which is the newest overall — an agent asking for
1324        // the newest travel mail confidently misses the newest flight
1325        // confirmation, and it reads as "no results", not "cannot see there".
1326        #[test]
1327        fn newest_first_is_global_across_accounts_not_per_account() {
1328            let one = messages(serde_json::json!({"mailbox": "Travel", "limit": 1}));
1329            assert_eq!(dates(&one), vec!["2026-08-20T00:00:00.000Z"]);
1330            assert_eq!(one["messages"][0]["account_id"], "ACC-2");
1331
1332            // And the full listing is in date order, not account order.
1333            let all = messages(serde_json::json!({"mailbox": "Travel", "limit": 10}));
1334            assert_eq!(
1335                dates(&all),
1336                vec![
1337                    "2026-08-20T00:00:00.000Z",
1338                    "2026-08-01T00:00:00.000Z",
1339                    "2026-01-01T00:00:00.000Z",
1340                ]
1341            );
1342        }
1343
1344        #[test]
1345        fn limit_is_shared_across_accounts() {
1346            let two = messages(serde_json::json!({"mailbox": "Travel", "limit": 2}));
1347            assert_eq!(two["messages"].as_array().unwrap().len(), 2);
1348            assert_eq!(
1349                dates(&two),
1350                vec!["2026-08-20T00:00:00.000Z", "2026-08-01T00:00:00.000Z"]
1351            );
1352        }
1353
1354        // The whole point of the issue: mail filed out of INBOX by a rule.
1355        #[test]
1356        fn nested_mailboxes_resolve_by_path_by_leaf_and_case_insensitively() {
1357            for selector in ["Travel/2026", "travel/2026", "2026"] {
1358                let v = messages(serde_json::json!({"mailbox": selector}));
1359                assert_eq!(
1360                    v["messages"][0]["subject"], "nested",
1361                    "selector {selector:?} should reach the nested mailbox"
1362                );
1363                // Rows are stamped with the RESOLVED path, not the selector
1364                // the caller typed, so they match `mail.mailboxes` output.
1365                assert_eq!(v["messages"][0]["mailbox"], "Travel/2026");
1366            }
1367            let cased = messages(serde_json::json!({"mailbox": "travel", "limit": 1}));
1368            assert_eq!(cased["messages"][0]["mailbox"], "Travel");
1369        }
1370
1371        // `since` compares instants, not formats, and is inclusive at the
1372        // instant itself — fixed by hand in efa541ea and unprotected until now.
1373        #[test]
1374        fn since_is_inclusive_at_the_instant_and_exclusive_one_ms_later() {
1375            let at = messages(serde_json::json!({
1376                "mailbox": "Travel", "account_ids": ["ACC-1"],
1377                "since": "2026-08-01T00:00:00.000Z"
1378            }));
1379            assert_eq!(dates(&at), vec!["2026-08-01T00:00:00.000Z"]);
1380
1381            let past = messages(serde_json::json!({
1382                "mailbox": "Travel", "account_ids": ["ACC-1"],
1383                "since": "2026-08-01T00:00:00.001Z"
1384            }));
1385            assert!(past["messages"].as_array().unwrap().is_empty());
1386        }
1387
1388        // `since` is applied BEFORE the limit, so a narrow window still yields
1389        // real matches instead of whatever survived a pre-filtered slice.
1390        #[test]
1391        fn since_filters_before_the_limit() {
1392            let v = messages(serde_json::json!({
1393                "mailbox": "Travel", "limit": 1, "since": "2026-02-01T00:00:00.000Z"
1394            }));
1395            assert_eq!(dates(&v), vec!["2026-08-20T00:00:00.000Z"]);
1396        }
1397
1398        // "Cannot see there" must never render as "there is nothing there".
1399        #[test]
1400        fn unreachable_targets_report_a_reason_rather_than_an_empty_list() {
1401            let no_box = messages(serde_json::json!({"mailbox": "Nope"}));
1402            assert_eq!(no_box["available"], false);
1403            assert!(no_box["reason"]
1404                .as_str()
1405                .unwrap()
1406                .contains("mail.mailboxes"));
1407
1408            let no_account =
1409                messages(serde_json::json!({"mailbox": "Travel", "account_ids": ["ACC-NOPE"]}));
1410            assert_eq!(no_account["available"], false);
1411            assert!(no_account["reason"].as_str().unwrap().contains("ACC-NOPE"));
1412
1413            let no_account_boxes = run(&["mailboxes", "ACC-NOPE"]);
1414            assert_eq!(no_account_boxes["available"], false);
1415            assert!(no_account_boxes["reason"]
1416                .as_str()
1417                .unwrap()
1418                .contains("mail.accounts"));
1419        }
1420
1421        #[test]
1422        fn mailbox_enumeration_includes_nested_mailboxes() {
1423            let v = run(&["mailboxes"]);
1424            let names: Vec<&str> = v["mailboxes"]
1425                .as_array()
1426                .unwrap()
1427                .iter()
1428                .map(|m| m["full_name"].as_str().unwrap())
1429                .collect();
1430            assert!(names.contains(&"Travel"), "{names:?}");
1431            assert!(names.contains(&"Travel/2026"), "{names:?}");
1432        }
1433
1434        // `{}` still means "the INBOX, newest 50" for every pre-existing caller.
1435        #[test]
1436        fn an_empty_query_still_reads_the_inbox() {
1437            let v = messages(serde_json::json!({}));
1438            let subjects: Vec<&str> = v["messages"]
1439                .as_array()
1440                .unwrap()
1441                .iter()
1442                .map(|m| m["subject"].as_str().unwrap())
1443                .collect();
1444            assert_eq!(subjects, vec!["inbox two", "inbox one"]);
1445        }
1446
1447        // The composite id has to survive the round trip back to a body fetch.
1448        #[test]
1449        fn row_ids_decode_to_the_account_and_resolved_mailbox() {
1450            let v = messages(serde_json::json!({"mailbox": "2026"}));
1451            let account = v["messages"][0]["account_id"].as_str().unwrap();
1452            let mailbox = v["messages"][0]["mailbox"].as_str().unwrap();
1453            let local = v["messages"][0]["local_id"].as_str().unwrap();
1454            let id = encode_message_id(account, mailbox, local);
1455            assert_eq!(
1456                decode_message_id(&id).unwrap(),
1457                MessageRef::MailApp {
1458                    account_id: "ACC-1".into(),
1459                    mailbox: "Travel/2026".into(),
1460                    local_id: "103".into(),
1461                }
1462            );
1463            let body = run(&["body", "ACC-1", "Travel/2026", "103", "100000"]);
1464            assert_eq!(body["available"], true);
1465            assert_eq!(body["body"], "body of 103");
1466        }
1467    }
1468
1469    #[test]
1470    fn listings_carry_the_availability_envelope_inline() {
1471        let listing = MailboxListing {
1472            availability: Availability::available("mail_app"),
1473            mailboxes: vec![Mailbox {
1474                account_id: "iCloud".into(),
1475                name: "Travel".into(),
1476                full_name: "Travel".into(),
1477                unread: 2,
1478                total: 17,
1479            }],
1480        };
1481        let v = serde_json::to_value(&listing).unwrap();
1482        assert_eq!(v["available"], serde_json::json!(true));
1483        assert_eq!(v["backend"], serde_json::json!("mail_app"));
1484        assert_eq!(v["mailboxes"][0]["full_name"], serde_json::json!("Travel"));
1485    }
1486}