Skip to main content

car_integrations/mail/
mod.rs

1//! Mail capability — enumerate accounts, summarize inbox, send/draft.
2//!
3//! Unlike Calendar + Contacts, Mail has no clean OS-native API on any
4//! platform that CAR targets. Real implementations will be a mix of:
5//!
6//! - **protocol client** — IMAP + SMTP against accounts known to the OS
7//!   (via [`car-accounts`](car_accounts)) with credentials from
8//!   [`car-secrets`](car_secrets)
9//! - **platform-specific inspection** — AppleScript on macOS for Mail.app,
10//!   MAPI on Windows for Outlook, Evolution Data Server on Linux
11//!
12//! Side-effecting operations (send, draft, delete) should always be
13//! approval-gated at the product layer — this crate exposes them but does
14//! not enforce consent UX.
15
16use serde::{Deserialize, Serialize};
17#[cfg(target_os = "macos")]
18use std::process::Command;
19
20use super::{Availability, IntegrationError};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct MailAccount {
24    pub id: String,
25    pub address: String,
26    pub display_name: Option<String>,
27    pub provider_hint: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct InboxSummary {
32    pub account_id: String,
33    pub unread: u32,
34    pub total: u32,
35    pub most_recent_subject: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct AccountListing {
40    #[serde(flatten)]
41    pub availability: Availability,
42    pub accounts: Vec<MailAccount>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct InboxListing {
47    #[serde(flatten)]
48    pub availability: Availability,
49    pub summaries: Vec<InboxSummary>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SendRequest {
54    pub account_id: String,
55    pub to: Vec<String>,
56    #[serde(default)]
57    pub cc: Vec<String>,
58    #[serde(default)]
59    pub bcc: Vec<String>,
60    pub subject: String,
61    pub body: String,
62    /// When `true`, just save the message as a draft — don't send.
63    #[serde(default)]
64    pub draft_only: bool,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SendResult {
69    #[serde(flatten)]
70    pub availability: Availability,
71    /// `true` if the message was actually sent or drafted.
72    pub sent: bool,
73    pub message_id: Option<String>,
74}
75
76pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
77    backend::list_accounts()
78}
79
80/// Short inbox summary per account. For accounts where the backend can't
81/// reach the server, the summary is omitted.
82pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
83    backend::list_inbox(account_ids)
84}
85
86/// Send or draft a message through the active platform mail backend.
87pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
88    backend::send(req)
89}
90
91#[cfg(target_os = "macos")]
92mod backend {
93    use super::*;
94
95    const JXA: &str = r#"
96function normalizeAccount(account) {
97  let name = "";
98  let id = "";
99  let addresses = [];
100  try { name = String(account.name()); } catch (e) {}
101  try { id = String(account.id()); } catch (e) {}
102  if (!id) id = name;
103  try { addresses = account.emailAddresses().map(String); } catch (e) {}
104  return {
105    id: id,
106    address: addresses[0] || name,
107    display_name: name || null,
108    provider_hint: null
109  };
110}
111
112function accountMatches(account, requested) {
113  if (requested.length === 0) return true;
114  const normalized = normalizeAccount(account);
115  return requested.indexOf(normalized.id) >= 0 || requested.indexOf(normalized.address) >= 0 || requested.indexOf(normalized.display_name || "") >= 0;
116}
117
118function mailApp() {
119  const app = Application("/System/Applications/Mail.app");
120  app.includeStandardAdditions = true;
121  return app;
122}
123
124function run(argv) {
125  const mode = argv[0] || "accounts";
126  let Mail;
127  try {
128    Mail = mailApp();
129  } catch (e) {
130    return JSON.stringify({available:false, backend:"mail_app", reason:String(e), accounts:[], summaries:[], sent:false, message_id:null});
131  }
132
133  if (mode === "accounts") {
134    try {
135      return JSON.stringify({available:true, backend:"mail_app", reason:null, accounts: Mail.accounts().map(normalizeAccount)});
136    } catch (e) {
137      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), accounts:[]});
138    }
139  }
140
141  if (mode === "inbox") {
142    const requested = argv.slice(1);
143    const summaries = [];
144    try {
145      Mail.accounts().forEach(account => {
146        if (!accountMatches(account, requested)) return;
147        const normalized = normalizeAccount(account);
148        let unread = 0;
149        let total = 0;
150        let subject = null;
151        try {
152          const inbox = account.mailboxes.byName("INBOX");
153          const messages = inbox.messages();
154          total = messages.length;
155          for (let i = 0; i < messages.length; i++) {
156            const message = messages[i];
157            try { if (message.readStatus() === false) unread += 1; } catch (e) {}
158            if (subject === null) {
159              try { subject = String(message.subject()); } catch (e) {}
160            }
161          }
162        } catch (e) {}
163        summaries.push({account_id: normalized.id, unread: unread, total: total, most_recent_subject: subject});
164      });
165      return JSON.stringify({available:true, backend:"mail_app", reason:null, summaries:summaries});
166    } catch (e) {
167      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), summaries:[]});
168    }
169  }
170
171  if (mode === "send") {
172    try {
173      const req = JSON.parse(argv[1] || "{}");
174      const msg = Mail.OutgoingMessage({
175        subject: req.subject || "",
176        content: req.body || "",
177        visible: false
178      });
179      Mail.outgoingMessages.push(msg);
180      (req.to || []).forEach(address => msg.toRecipients.push(Mail.Recipient({address: String(address)})));
181      (req.cc || []).forEach(address => msg.ccRecipients.push(Mail.Recipient({address: String(address)})));
182      (req.bcc || []).forEach(address => msg.bccRecipients.push(Mail.Recipient({address: String(address)})));
183      if (req.account_id) {
184        const accounts = Mail.accounts();
185        let matched = null;
186        for (let i = 0; i < accounts.length; i++) {
187          const normalized = normalizeAccount(accounts[i]);
188          if (normalized.id === req.account_id || normalized.address === req.account_id || normalized.display_name === req.account_id) {
189            matched = normalized;
190            break;
191          }
192        }
193        // A specified-but-unresolvable account must NOT silently fall
194        // through to Mail's default outgoing account (car-releases#47).
195        if (!matched) {
196          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});
197        }
198        // The JXA `sender` setter throws under some Mail/account-type
199        // combos (EWS vs IMAP) and can also no-op without throwing.
200        // Set it, then read it back and confirm the address actually
201        // took — never assume success.
202        let setError = null;
203        try { msg.sender(matched.address); } catch (e) { setError = String(e); }
204        let effective = null;
205        try { effective = String(msg.sender()); } catch (e) {}
206        const wanted = String(matched.address || "").toLowerCase();
207        const took = wanted.length > 0 && effective !== null &&
208                     String(effective).toLowerCase().indexOf(wanted) !== -1;
209        if (!took) {
210          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});
211        }
212      }
213      if (req.draft_only) {
214        msg.save();
215      } else {
216        msg.send();
217      }
218      let messageId = null;
219      try { messageId = String(msg.id()); } catch (e) {}
220      return JSON.stringify({available:true, backend:"mail_app", reason:null, sent:true, message_id:messageId});
221    } catch (e) {
222      return JSON.stringify({available:false, backend:"mail_app", reason:String(e), sent:false, message_id:null});
223    }
224  }
225
226  return JSON.stringify({available:false, backend:"mail_app", reason:"unknown mail mode", accounts:[], summaries:[], sent:false, message_id:null});
227}
228"#;
229
230    pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
231        let mail_listing: AccountListing = run_jxa(&["accounts"])?;
232        if mail_listing.availability.available || !mail_listing.accounts.is_empty() {
233            return Ok(mail_listing);
234        }
235
236        let accounts = car_accounts::list()
237            .map_err(|e| IntegrationError::Backend(format!("accounts fallback: {e}")))?
238            .accounts
239            .into_iter()
240            .filter(|account| account.capabilities.iter().any(|cap| cap == "mail"))
241            .map(|account| MailAccount {
242                id: account.id,
243                address: account.identifier.unwrap_or(account.label.clone()),
244                display_name: Some(account.label),
245                provider_hint: Some(account.provider),
246            })
247            .collect();
248
249        Ok(AccountListing {
250            availability: Availability::available("internet_accounts"),
251            accounts,
252        })
253    }
254
255    pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
256        let mut args = vec!["inbox"];
257        args.extend(account_ids.iter().map(String::as_str));
258        run_jxa(&args)
259    }
260
261    pub fn send(req: SendRequest) -> Result<SendResult, IntegrationError> {
262        let req_json = serde_json::to_string(&req)
263            .map_err(|e| IntegrationError::Backend(format!("mail request json: {e}")))?;
264        run_jxa(&["send", req_json.as_str()])
265    }
266
267    fn run_jxa<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
268        let output = Command::new("/usr/bin/osascript")
269            .arg("-l")
270            .arg("JavaScript")
271            .arg("-")
272            .args(args)
273            .stdin(std::process::Stdio::piped())
274            .stdout(std::process::Stdio::piped())
275            .stderr(std::process::Stdio::piped())
276            .spawn()
277            .and_then(|mut child| {
278                use std::io::Write;
279                if let Some(stdin) = child.stdin.as_mut() {
280                    stdin.write_all(JXA.as_bytes())?;
281                }
282                child.wait_with_output()
283            })
284            .map_err(|e| IntegrationError::Backend(format!("osascript: {e}")))?;
285
286        if !output.status.success() {
287            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
288            return Err(IntegrationError::Backend(format!(
289                "mail osascript failed: {stderr}"
290            )));
291        }
292
293        serde_json::from_slice(&output.stdout)
294            .map_err(|e| IntegrationError::Backend(format!("mail json: {e}")))
295    }
296}
297
298#[cfg(not(target_os = "macos"))]
299mod backend {
300    use super::*;
301
302    pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
303        Ok(AccountListing {
304            availability: current_backend_pending(),
305            accounts: vec![],
306        })
307    }
308
309    pub fn list_inbox(_account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
310        Ok(InboxListing {
311            availability: current_backend_pending(),
312            summaries: vec![],
313        })
314    }
315
316    pub fn send(_req: SendRequest) -> Result<SendResult, IntegrationError> {
317        Ok(SendResult {
318            availability: current_backend_pending(),
319            sent: false,
320            message_id: None,
321        })
322    }
323
324    fn current_backend_pending() -> Availability {
325        Availability::pending(
326            "imap_smtp",
327            "Mail requires IMAP/SMTP client + per-OS Mail app inspection, \
328             not yet wired. Depends on car-accounts (which accounts to use) \
329             and car-secrets (credentials). API shape is stable.",
330        )
331    }
332}