car-integrations 0.47.0

OS-native account-bound integrations (Calendar, Contacts, Mail) for CAR
Documentation
//! Mail capability — enumerate accounts, summarize inbox, send/draft.
//!
//! Unlike Calendar + Contacts, Mail has no clean *local* OS-native read API
//! off macOS — a real, structural parity gap, not merely unwired plumbing:
//!
//! - **macOS** — the one platform with a zero-setup local backend: JXA against
//!   Mail.app (below).
//! - **Windows** — there is no populated local mail store to read. The People
//!   and Calendar apps aggregate the user's connected accounts into the WinRT
//!   Contacts/Appointments stores (which is exactly why `windows_contacts` /
//!   `windows_calendar` read locally, unpackaged), but *no* modern mail app
//!   plays the equivalent `Windows.ApplicationModel.Email.EmailStore`
//!   data-provider role — the old Mail app is deprecated and the new Outlook is
//!   cloud-only. Verified 2026-07-26 from an unpackaged exe on a box with real
//!   connected accounts (Contacts/Calendar returned data):
//!   `EmailManager::RequestStoreAsync` succeeds but `FindMailboxesAsync`
//!   returns **0 mailboxes** for both `AppMailboxesReadWrite` and
//!   `AllMailboxesLimitedReadWrite`. Whether that 0 is the restricted `email`
//!   capability silently denying enumeration for an unpackaged process or the
//!   absent data provider, the outcome for CAR's unpackaged `.exe` is the same:
//!   nothing to read, so a WinRT mail backend is not viable. COM/MAPI reaches
//!   only *classic* Outlook (not new Outlook), needs it installed + running,
//!   and trips the Outlook programmatic-access security model — not a clean
//!   parity backend either. So do not re-chase `EmailStore` or MAPI here.
//! - **Linux** — no local backend (Evolution Data Server would be the candidate).
//!
//! Off macOS the backend is therefore **Microsoft Graph** (M365 mail, when
//! `CAR_MSGRAPH_CLIENT_ID` is set) — the same account CAR already reaches for
//! Graph contacts/calendar. The genuinely cross-platform *local* answer is an
//! IMAP + SMTP client keyed off [`car-accounts`](car_accounts) with credentials
//! from [`car-secrets`](car_secrets); it is not yet wired and, needing
//! per-account credentials, is not the zero-setup parity Mail.app gives macOS.
//!
//! Side-effecting operations (send, draft, delete) should always be
//! approval-gated at the product layer — this crate exposes them but does
//! not enforce consent UX.

use serde::{Deserialize, Serialize};

use super::{Availability, IntegrationError};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MailAccount {
    pub id: String,
    pub address: String,
    pub display_name: Option<String>,
    pub provider_hint: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboxSummary {
    pub account_id: String,
    pub unread: u32,
    pub total: u32,
    pub most_recent_subject: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountListing {
    #[serde(flatten)]
    pub availability: Availability,
    pub accounts: Vec<MailAccount>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboxListing {
    #[serde(flatten)]
    pub availability: Availability,
    pub summaries: Vec<InboxSummary>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendRequest {
    pub account_id: String,
    pub to: Vec<String>,
    #[serde(default)]
    pub cc: Vec<String>,
    #[serde(default)]
    pub bcc: Vec<String>,
    pub subject: String,
    pub body: String,
    /// When `true`, just save the message as a draft — don't send.
    #[serde(default)]
    pub draft_only: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendResult {
    #[serde(flatten)]
    pub availability: Availability,
    /// `true` if the message was actually sent or drafted.
    pub sent: bool,
    pub message_id: Option<String>,
}

pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
    backend::list_accounts()
}

/// Short inbox summary per account. For accounts where the backend can't
/// reach the server, the summary is omitted.
pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
    backend::list_inbox(account_ids)
}

/// Send or draft a message through the active platform mail backend.
pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
    backend::send(req)
}

#[cfg(target_os = "macos")]
mod backend {
    use super::*;

    const JXA: &str = r#"
function normalizeAccount(account) {
  let name = "";
  let id = "";
  let addresses = [];
  try { name = String(account.name()); } catch (e) {}
  try { id = String(account.id()); } catch (e) {}
  if (!id) id = name;
  try { addresses = account.emailAddresses().map(String); } catch (e) {}
  return {
    id: id,
    address: addresses[0] || name,
    display_name: name || null,
    provider_hint: null
  };
}

function accountMatches(account, requested) {
  if (requested.length === 0) return true;
  const normalized = normalizeAccount(account);
  return requested.indexOf(normalized.id) >= 0 || requested.indexOf(normalized.address) >= 0 || requested.indexOf(normalized.display_name || "") >= 0;
}

function mailApp() {
  const app = Application("/System/Applications/Mail.app");
  app.includeStandardAdditions = true;
  return app;
}

function run(argv) {
  const mode = argv[0] || "accounts";
  let Mail;
  try {
    Mail = mailApp();
  } catch (e) {
    return JSON.stringify({available:false, backend:"mail_app", reason:String(e), accounts:[], summaries:[], sent:false, message_id:null});
  }

  if (mode === "accounts") {
    try {
      return JSON.stringify({available:true, backend:"mail_app", reason:null, accounts: Mail.accounts().map(normalizeAccount)});
    } catch (e) {
      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), accounts:[]});
    }
  }

  if (mode === "inbox") {
    const requested = argv.slice(1);
    const summaries = [];
    try {
      Mail.accounts().forEach(account => {
        if (!accountMatches(account, requested)) return;
        const normalized = normalizeAccount(account);
        let unread = 0;
        let total = 0;
        let subject = null;
        try {
          const inbox = account.mailboxes.byName("INBOX");
          const messages = inbox.messages();
          total = messages.length;
          for (let i = 0; i < messages.length; i++) {
            const message = messages[i];
            try { if (message.readStatus() === false) unread += 1; } catch (e) {}
            if (subject === null) {
              try { subject = String(message.subject()); } catch (e) {}
            }
          }
        } catch (e) {}
        summaries.push({account_id: normalized.id, unread: unread, total: total, most_recent_subject: subject});
      });
      return JSON.stringify({available:true, backend:"mail_app", reason:null, summaries:summaries});
    } catch (e) {
      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), summaries:[]});
    }
  }

  if (mode === "send") {
    try {
      const req = JSON.parse(argv[1] || "{}");
      const msg = Mail.OutgoingMessage({
        subject: req.subject || "",
        content: req.body || "",
        visible: false
      });
      Mail.outgoingMessages.push(msg);
      (req.to || []).forEach(address => msg.toRecipients.push(Mail.Recipient({address: String(address)})));
      (req.cc || []).forEach(address => msg.ccRecipients.push(Mail.Recipient({address: String(address)})));
      (req.bcc || []).forEach(address => msg.bccRecipients.push(Mail.Recipient({address: String(address)})));
      if (req.account_id) {
        const accounts = Mail.accounts();
        let matched = null;
        for (let i = 0; i < accounts.length; i++) {
          const normalized = normalizeAccount(accounts[i]);
          if (normalized.id === req.account_id || normalized.address === req.account_id || normalized.display_name === req.account_id) {
            matched = normalized;
            break;
          }
        }
        // A specified-but-unresolvable account must NOT silently fall
        // through to Mail's default outgoing account (car-releases#47).
        if (!matched) {
          return JSON.stringify({available:true, backend:"mail_app", reason:"sender_override_failed: requested account "+String(req.account_id)+" not found", sent:false, message_id:null});
        }
        // The JXA `sender` setter throws under some Mail/account-type
        // combos (EWS vs IMAP) and can also no-op without throwing.
        // Set it, then read it back and confirm the address actually
        // took — never assume success.
        let setError = null;
        try { msg.sender(matched.address); } catch (e) { setError = String(e); }
        let effective = null;
        try { effective = String(msg.sender()); } catch (e) {}
        const wanted = String(matched.address || "").toLowerCase();
        const took = wanted.length > 0 && effective !== null &&
                     String(effective).toLowerCase().indexOf(wanted) !== -1;
        if (!took) {
          return JSON.stringify({available:true, backend:"mail_app", reason:"sender_override_failed: requested "+matched.address+", effective "+String(effective)+(setError ? " (setter error: "+setError+")" : ""), sent:false, message_id:null});
        }
      }
      if (req.draft_only) {
        msg.save();
      } else {
        msg.send();
      }
      let messageId = null;
      try { messageId = String(msg.id()); } catch (e) {}
      return JSON.stringify({available:true, backend:"mail_app", reason:null, sent:true, message_id:messageId});
    } catch (e) {
      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), sent:false, message_id:null});
    }
  }

  return JSON.stringify({available:false, backend:"mail_app", reason:"unknown mail mode", accounts:[], summaries:[], sent:false, message_id:null});
}
"#;

    pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
        let mail_listing: AccountListing = run_jxa(&["accounts"])?;
        if mail_listing.availability.available || !mail_listing.accounts.is_empty() {
            return Ok(mail_listing);
        }

        let accounts = car_accounts::list()
            .map_err(|e| IntegrationError::Backend(format!("accounts fallback: {e}")))?
            .accounts
            .into_iter()
            .filter(|account| account.capabilities.iter().any(|cap| cap == "mail"))
            .map(|account| MailAccount {
                id: account.id,
                address: account.identifier.unwrap_or(account.label.clone()),
                display_name: Some(account.label),
                provider_hint: Some(account.provider),
            })
            .collect();

        Ok(AccountListing {
            availability: Availability::available("internet_accounts"),
            accounts,
        })
    }

    pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
        let mut args = vec!["inbox"];
        args.extend(account_ids.iter().map(String::as_str));
        run_jxa(&args)
    }

    pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
        let req_json = serde_json::to_string(&req)
            .map_err(|e| IntegrationError::Backend(format!("mail request json: {e}")))?;
        run_jxa(&["send", req_json.as_str()])
    }

    /// Thin wrapper over [`crate::jxa::run`]. This used to be its own copy of
    /// the spawn/write/wait dance with a plain blocking `wait_with_output()`,
    /// so when Mail did not answer an Apple Event the call sat on Apple's ~120s
    /// default: `mail.accounts` took a measured 121.0s to return — and then
    /// reported `available: true` with an empty account list, a "success" that
    /// cost two minutes (Parslee-ai/car#618).
    fn run_jxa<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
        crate::jxa::run(JXA, args, crate::jxa::DEFAULT_TIMEOUT)
    }
}

#[cfg(not(target_os = "macos"))]
mod backend {
    use super::*;

    pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
        if crate::msgraph::is_configured() {
            return Ok(AccountListing {
                availability: Availability::available("msgraph"),
                accounts: vec![MailAccount {
                    id: "msgraph".into(),
                    address: String::new(),
                    display_name: Some("Microsoft 365".into()),
                    provider_hint: Some("microsoft".into()),
                }],
            });
        }
        Ok(AccountListing {
            availability: current_backend_pending(),
            accounts: vec![],
        })
    }

    pub fn list_inbox(_account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
        // Microsoft Graph backend (car#520) when configured; else pending.
        if crate::msgraph::is_configured() {
            return Ok(match crate::msgraph::inbox_summary("msgraph") {
                Ok(summary) => InboxListing {
                    availability: Availability::available("msgraph"),
                    summaries: vec![summary],
                },
                Err(e) => InboxListing {
                    availability: Availability::pending("msgraph", e.to_string()),
                    summaries: vec![],
                },
            });
        }
        Ok(InboxListing {
            availability: current_backend_pending(),
            summaries: vec![],
        })
    }

    pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
        // Microsoft Graph send/draft (car#531) when configured; else pending.
        if crate::msgraph::is_configured() {
            return Ok(match crate::msgraph::send_mail(&req) {
                Ok(message_id) => SendResult {
                    availability: Availability::available("msgraph"),
                    sent: true,
                    message_id,
                },
                Err(e) => SendResult {
                    availability: Availability::pending("msgraph", e.to_string()),
                    sent: false,
                    message_id: None,
                },
            });
        }
        Ok(SendResult {
            availability: current_backend_pending(),
            sent: false,
            message_id: None,
        })
    }

    fn current_backend_pending() -> Availability {
        Availability::pending(
            "imap_smtp",
            "Set CAR_MSGRAPH_CLIENT_ID (Azure AD app) to enable the Microsoft \
             Graph mail backend (car#520/#531 — inbox + send/draft); a local \
             IMAP/SMTP backend is not yet wired.",
        )
    }
}