use serde::{Deserialize, Serialize};
use super::{Availability, IntegrationError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MailAccount {
pub id: String,
pub address: String,
pub display_name: Option<String>,
pub provider_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboxSummary {
pub account_id: String,
pub unread: u32,
pub total: u32,
pub most_recent_subject: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountListing {
#[serde(flatten)]
pub availability: Availability,
pub accounts: Vec<MailAccount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboxListing {
#[serde(flatten)]
pub availability: Availability,
pub summaries: Vec<InboxSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendRequest {
pub account_id: String,
pub to: Vec<String>,
#[serde(default)]
pub cc: Vec<String>,
#[serde(default)]
pub bcc: Vec<String>,
pub subject: String,
pub body: String,
#[serde(default)]
pub draft_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendResult {
#[serde(flatten)]
pub availability: Availability,
pub sent: bool,
pub message_id: Option<String>,
}
pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
backend::list_accounts()
}
pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
backend::list_inbox(account_ids)
}
pub fn 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()])
}
fn run_jxa<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
crate::jxa::run(JXA, args, crate::jxa::DEFAULT_TIMEOUT)
}
}
#[cfg(not(target_os = "macos"))]
mod backend {
use super::*;
pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(AccountListing {
availability: Availability::available("msgraph"),
accounts: vec![MailAccount {
id: "msgraph".into(),
address: String::new(),
display_name: Some("Microsoft 365".into()),
provider_hint: Some("microsoft".into()),
}],
});
}
Ok(AccountListing {
availability: current_backend_pending(),
accounts: vec![],
})
}
pub fn list_inbox(_account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::inbox_summary("msgraph") {
Ok(summary) => InboxListing {
availability: Availability::available("msgraph"),
summaries: vec![summary],
},
Err(e) => InboxListing {
availability: Availability::pending("msgraph", e.to_string()),
summaries: vec![],
},
});
}
Ok(InboxListing {
availability: current_backend_pending(),
summaries: vec![],
})
}
pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
if crate::msgraph::is_configured() {
return Ok(match crate::msgraph::send_mail(&req) {
Ok(message_id) => SendResult {
availability: Availability::available("msgraph"),
sent: true,
message_id,
},
Err(e) => SendResult {
availability: Availability::pending("msgraph", e.to_string()),
sent: false,
message_id: None,
},
});
}
Ok(SendResult {
availability: current_backend_pending(),
sent: false,
message_id: None,
})
}
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.",
)
}
}