1use 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 #[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 pub sent: bool,
108 pub message_id: Option<String>,
109}
110
111#[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#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct MessageSummary {
138 pub id: String,
142 pub account_id: String,
143 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct MessageQuery {
176 #[serde(default)]
177 pub account_ids: Vec<String>,
178 #[serde(default)]
181 pub mailbox: Option<String>,
182 #[serde(default = "default_limit")]
183 pub limit: usize,
184 #[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 pub id: String,
216 pub content_type: String,
218 pub body: Option<String>,
219 pub truncated: bool,
221}
222
223pub const DEFAULT_MAILBOX: &str = "INBOX";
225
226pub const MESSAGE_BODY_CAP: usize = 100_000;
232
233const MAILAPP_ID_PREFIX: &str = "mailapp";
234const MSGRAPH_ID_PREFIX: &str = "msgraph";
235
236#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum MessageRef {
239 MailApp {
244 account_id: String,
245 mailbox: String,
246 local_id: String,
247 },
248 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
260pub 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
272pub fn encode_graph_message_id(graph_id: &str) -> String {
275 format!("{MSGRAPH_ID_PREFIX}:{graph_id}")
276}
277
278pub 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 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
315pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
318 backend::list_inbox(account_ids)
319}
320
321pub fn list_mailboxes(account_ids: &[String]) -> Result<MailboxListing, IntegrationError> {
330 backend::list_mailboxes(account_ids)
331}
332
333pub fn list_messages(query: MessageQuery) -> Result<MessageListing, IntegrationError> {
342 backend::list_messages(query)
343}
344
345pub fn message_body(message_id: &str) -> Result<MessageBodyResult, IntegrationError> {
347 backend::message_body(message_id)
348}
349
350pub 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) 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 #[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 "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 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 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 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 #[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 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 #[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 #[cfg(target_os = "macos")]
1232 mod jxa {
1233 use super::*;
1234
1235 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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}