1use 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 #[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 pub sent: bool,
73 pub message_id: Option<String>,
74}
75
76pub fn list_accounts() -> Result<AccountListing, IntegrationError> {
77 backend::list_accounts()
78}
79
80pub fn list_inbox(account_ids: &[String]) -> Result<InboxListing, IntegrationError> {
83 backend::list_inbox(account_ids)
84}
85
86pub 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}