mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git
src/ipc.rs (21.9K)
1 //! The control socket: `/tmp/cce-mail-<WAYLAND_DISPLAY>.sock`.
2 //!
3 //! Line-oriented request/reply, the same shape as the compositor's control
4 //! socket: a client connects, writes one command line, and reads the reply
5 //! to EOF. `cce-mail ctl <command…>` is the CLI client ([`run_client`]);
6 //! `cce-mail ctl help` prints [`HELP`], the command list.
7 //!
8 //! Every command is answered by the app on its main thread, from the same
9 //! state the window paints — the listener thread only reads the line and
10 //! hands it over as [`AppMessage::Ipc`], carrying the stream so the reply
11 //! can be written once the app has one. Nothing here reads the on-disk
12 //! cache: it lags the window by a sync, and a reader would still have to
13 //! re-derive which account and folder the user is looking at.
14 //!
15 //! Replies are text by default — one row per line, tab-separated columns,
16 //! `error: …` on failure — and JSON with `--json`, for agents and scripts.
17 //! Account rows never carry passwords or tokens.
18
19 use std::io::Write;
20 use std::os::unix::net::{UnixListener, UnixStream};
21 use std::sync::{Arc, Mutex};
22 use std::time::Duration;
23
24 use crate::{AccountInfo, AppMessage, Email, FolderInfo};
25
26 /// Socket prefix; `cce_ui::ipc::socket_path` appends `-<WAYLAND_DISPLAY>`.
27 const PREFIX: &str = "cce-mail";
28
29 /// Bound on any one socket read or write. A client that connects and never
30 /// sends would otherwise park the listener thread forever, and one that
31 /// vanished mid-reply would park the app's main thread.
32 const IO_TIMEOUT: Duration = Duration::from_secs(2);
33
34 /// Rows `list` and `search` return when no `--limit` is given.
35 pub const DEFAULT_LIMIT: usize = 50;
36
37 /// The socket path this process bound (and must unlink on exit), if any.
38 static OWNED_PATH: Mutex<Option<String>> = Mutex::new(None);
39
40 pub const HELP: &str = "\
41 cce-mail ctl <command> [--json]
42
43 help this list
44 status account, folder, counts, sync state
45 accounts the configured accounts
46 folders the selected account's folders, with counts
47 list [folder] [--limit N] [--unread]
48 messages in a folder (default: the open one), newest first
49 search <words…> [--limit N] messages whose from/subject/body contain the words, every folder
50 get <id> one message: headers and the cached text body (a preview, up to 1200 chars)
51 open <id> show a message in the window (marks it read, like a click)
52 mark-read <id> set the read flag; mirrored to the server for inbox mail
53 mark-unread <id>
54 select-account <email>
55 switch-folder <folder> by tag (inbox, sent, drafts, trash, …) or label
56 sync start a sync now
57 compose [mailto:…] open the compose dialog, prefilled from the URL if given
58 quit
59
60 Text replies are tab-separated rows (list/search: id, read state, folder,
61 date, from, subject); --json returns JSON instead. Message ids are the app's
62 own and can change between syncs — take them from a fresh list.";
63
64 /// One command line off the socket, plus the stream to answer it on.
65 ///
66 /// Clone-able (the engine's message type must be), so the stream sits behind
67 /// an `Arc`; the first [`respond`](Self::respond) takes it and later ones are
68 /// no-ops.
69 #[derive(Debug, Clone)]
70 pub struct Request {
71 pub line: String,
72 stream: Arc<Mutex<Option<UnixStream>>>,
73 }
74
75 impl Request {
76 /// Write `reply` (newline-terminated) and close the write side, which is
77 /// the EOF the client reads to.
78 pub fn respond(&self, reply: &str) {
79 let Some(mut stream) = self.stream.lock().ok().and_then(|mut s| s.take()) else {
80 return;
81 };
82 let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
83 let _ = stream.write_all(reply.as_bytes());
84 if !reply.ends_with('\n') {
85 let _ = stream.write_all(b"\n");
86 }
87 let _ = stream.shutdown(std::net::Shutdown::Write);
88 }
89 }
90
91 /// Bind the control socket and serve it on a thread, pushing each received
92 /// line into the app's calloop channel as [`AppMessage::Ipc`].
93 ///
94 /// A second instance finds the socket answering and runs without one — the
95 /// window still works, it just cannot be driven. A socket file nobody
96 /// answers on is a leftover from a crash and is replaced.
97 pub fn spawn_listener(sender: calloop::channel::Sender<AppMessage>) {
98 let path = cce_ui::ipc::socket_path(PREFIX);
99 if std::path::Path::new(&path).exists() {
100 if UnixStream::connect(&path).is_ok() {
101 eprintln!("cce-mail: another instance answers on {path}; this one runs without a control socket");
102 return;
103 }
104 let _ = std::fs::remove_file(&path);
105 }
106 let listener = match UnixListener::bind(&path) {
107 Ok(l) => l,
108 Err(e) => {
109 eprintln!("cce-mail: could not bind the control socket {path} ({e})");
110 return;
111 }
112 };
113 *OWNED_PATH.lock().unwrap() = Some(path);
114 std::thread::spawn(move || {
115 for conn in listener.incoming() {
116 let Ok(conn) = conn else { continue };
117 let Some(line) = cce_ui::ipc::read_request_line(&conn, 64 * 1024, IO_TIMEOUT) else {
118 continue;
119 };
120 let _ = conn.set_read_timeout(Some(IO_TIMEOUT));
121 let req = Request {
122 line: line.trim().to_string(),
123 stream: Arc::new(Mutex::new(Some(conn))),
124 };
125 if sender.send(AppMessage::Ipc(req)).is_err() {
126 return; // channel gone: the app is shutting down
127 }
128 }
129 });
130 }
131
132 /// Unlink the socket if this process bound it. Called after the engine loop
133 /// returns; a crash skips it, which is what the stale-socket replacement in
134 /// [`spawn_listener`] exists for.
135 pub fn cleanup() {
136 if let Some(path) = OWNED_PATH.lock().unwrap().take() {
137 let _ = std::fs::remove_file(path);
138 }
139 }
140
141 /// `cce-mail ctl <args…>`: send the line, print the reply, return the exit
142 /// code — 0 on success, 1 when the app answered `error:`, 2 when no app
143 /// answered at all. `help` (or nothing) prints [`HELP`] without a socket.
144 pub fn run_client(args: &[String]) -> i32 {
145 let line = args.join(" ");
146 let line = line.trim();
147 if line.is_empty() || line == "help" {
148 println!("{HELP}");
149 return 0;
150 }
151 match cce_ui::ipc::send_command(PREFIX, line) {
152 Ok(reply) => {
153 print!("{reply}");
154 if reply.starts_with("error:") {
155 1
156 } else {
157 0
158 }
159 }
160 Err(e) => {
161 eprintln!(
162 "error: cce-mail is not reachable at {} ({e})",
163 cce_ui::ipc::socket_path(PREFIX)
164 );
165 2
166 }
167 }
168 }
169
170 // ---------------------------------------------------------------------------
171 // Parsing
172
173 #[derive(Debug, Clone, PartialEq)]
174 pub enum Command {
175 Help,
176 Status,
177 Accounts,
178 Folders,
179 List { folder: Option<String>, limit: usize, unread: bool },
180 Search { query: String, limit: usize },
181 Get(usize),
182 Open(usize),
183 MarkRead(usize, bool),
184 SelectAccount(String),
185 SwitchFolder(String),
186 Sync,
187 Compose(Option<String>),
188 Quit,
189 }
190
191 #[derive(Debug, Clone, PartialEq)]
192 pub struct Parsed {
193 pub command: Command,
194 /// `--json` was given: render the reply as JSON.
195 pub json: bool,
196 }
197
198 /// Parse one command line. Flags (`--json`, `--unread`, `--limit N`) may
199 /// sit anywhere; the remaining words are the command and its arguments.
200 pub fn parse(line: &str) -> Result<Parsed, String> {
201 let mut json = false;
202 let mut unread = false;
203 let mut limit = DEFAULT_LIMIT;
204 let mut words: Vec<&str> = Vec::new();
205 let mut it = line.split_whitespace();
206 while let Some(w) = it.next() {
207 match w {
208 "--json" => json = true,
209 "--unread" => unread = true,
210 "--limit" | "-n" => {
211 let v = it.next().ok_or("--limit needs a number")?;
212 limit = v.parse().map_err(|_| format!("bad limit {v:?}"))?;
213 }
214 _ if w.starts_with("--") => return Err(format!("unknown flag {w}")),
215 _ => words.push(w),
216 }
217 }
218 let Some((&cmd, rest)) = words.split_first() else {
219 return Err("empty command (try `help`)".to_string());
220 };
221 let one_id = |rest: &[&str]| -> Result<usize, String> {
222 match rest {
223 [v] => v.parse().map_err(|_| format!("bad message id {v:?}")),
224 _ => Err(format!("{cmd} takes exactly one message id")),
225 }
226 };
227 let no_args = |rest: &[&str], c: Command| -> Result<Command, String> {
228 if rest.is_empty() {
229 Ok(c)
230 } else {
231 Err(format!("{cmd} takes no arguments"))
232 }
233 };
234 let command = match cmd {
235 "help" => no_args(rest, Command::Help)?,
236 "status" => no_args(rest, Command::Status)?,
237 "accounts" => no_args(rest, Command::Accounts)?,
238 "folders" => no_args(rest, Command::Folders)?,
239 "sync" => no_args(rest, Command::Sync)?,
240 "quit" => no_args(rest, Command::Quit)?,
241 // Folder labels can carry spaces ("All Mail"), so the rest is the name.
242 "list" => Command::List {
243 folder: (!rest.is_empty()).then(|| rest.join(" ")),
244 limit,
245 unread,
246 },
247 "search" => {
248 if rest.is_empty() {
249 return Err("search needs at least one word".to_string());
250 }
251 Command::Search { query: rest.join(" "), limit }
252 }
253 "get" => Command::Get(one_id(rest)?),
254 "open" => Command::Open(one_id(rest)?),
255 "mark-read" => Command::MarkRead(one_id(rest)?, true),
256 "mark-unread" => Command::MarkRead(one_id(rest)?, false),
257 "select-account" => match rest {
258 [e] => Command::SelectAccount(e.to_string()),
259 _ => return Err("select-account takes one email address".to_string()),
260 },
261 "switch-folder" => {
262 if rest.is_empty() {
263 return Err("switch-folder needs a folder tag or label".to_string());
264 }
265 Command::SwitchFolder(rest.join(" "))
266 }
267 "compose" => match rest {
268 [] => Command::Compose(None),
269 [u] => Command::Compose(Some(u.to_string())),
270 _ => return Err("compose takes at most one mailto: URL".to_string()),
271 },
272 other => return Err(format!("unknown command {other:?} (try `help`)")),
273 };
274 Ok(Parsed { command, json })
275 }
276
277 // ---------------------------------------------------------------------------
278 // Rendering
279
280 pub fn error(msg: &str) -> String {
281 format!("error: {msg}")
282 }
283
284 /// A success acknowledgement for a command that changes state.
285 pub fn ok(what: &str, json: bool) -> String {
286 if json {
287 serde_json::json!({ "ok": what }).to_string()
288 } else {
289 format!("ok: {what}")
290 }
291 }
292
293 /// One text cell: tabs and newlines would break the row grammar.
294 fn cell(s: &str) -> String {
295 s.replace(['\t', '\n', '\r'], " ")
296 }
297
298 /// Attachment names as the user sees them: what the server reported for
299 /// fetched mail, the local file names for drafts and sent copies.
300 fn attachment_names(e: &Email) -> Vec<String> {
301 if !e.remote_attachments.is_empty() {
302 e.remote_attachments.iter().map(|a| a.name.clone()).collect()
303 } else {
304 e.attachments
305 .iter()
306 .map(|p| {
307 std::path::Path::new(p)
308 .file_name()
309 .map(|n| n.to_string_lossy().into_owned())
310 .unwrap_or_else(|| p.clone())
311 })
312 .collect()
313 }
314 }
315
316 fn row_json(e: &Email) -> serde_json::Value {
317 serde_json::json!({
318 "id": e.id,
319 "folder": e.folder,
320 "read": e.read,
321 "date": e.date,
322 "ts": e.ts,
323 "from": e.from,
324 "to": e.to,
325 "subject": e.subject,
326 "attachments": attachment_names(e),
327 })
328 }
329
330 /// `list` / `search` rows, in the order given (the app's list order: newest first).
331 pub fn render_rows(rows: &[&Email], json: bool) -> String {
332 if json {
333 return serde_json::Value::Array(rows.iter().map(|e| row_json(e)).collect()).to_string();
334 }
335 rows.iter()
336 .map(|e| {
337 format!(
338 "{}\t{}\t{}\t{}\t{}\t{}",
339 e.id,
340 if e.read { "read" } else { "unread" },
341 cell(&e.folder),
342 cell(&e.date),
343 cell(&e.from),
344 cell(&e.subject),
345 )
346 })
347 .collect::<Vec<_>>()
348 .join("\n")
349 }
350
351 /// `get`: the whole record. Text is headers, a blank line, then the body.
352 pub fn render_email(e: &Email, json: bool) -> String {
353 if json {
354 return serde_json::to_string(e).unwrap_or_else(|err| error(&err.to_string()));
355 }
356 let mut out = String::new();
357 out.push_str(&format!("id: {}\n", e.id));
358 out.push_str(&format!("folder: {}\n", cell(&e.folder)));
359 out.push_str(&format!("from: {}\n", cell(&e.from)));
360 out.push_str(&format!("to: {}\n", cell(&e.to)));
361 if !e.cc.is_empty() {
362 out.push_str(&format!("cc: {}\n", cell(&e.cc)));
363 }
364 if !e.bcc.is_empty() {
365 out.push_str(&format!("bcc: {}\n", cell(&e.bcc)));
366 }
367 out.push_str(&format!("date: {}\n", cell(&e.date)));
368 out.push_str(&format!("subject: {}\n", cell(&e.subject)));
369 out.push_str(&format!("read: {}\n", if e.read { "yes" } else { "no" }));
370 let names = attachment_names(e);
371 if !names.is_empty() {
372 out.push_str(&format!("attachments: {}\n", cell(&names.join(", "))));
373 }
374 out.push('\n');
375 out.push_str(&e.body);
376 out
377 }
378
379 /// `accounts`. Never the password, tokens or client secret.
380 pub fn render_accounts(accounts: &[AccountInfo], selected: usize, json: bool) -> String {
381 if json {
382 let rows: Vec<serde_json::Value> = accounts
383 .iter()
384 .enumerate()
385 .map(|(i, a)| {
386 serde_json::json!({
387 "email": a.email,
388 "imap": a.imap,
389 "smtp": a.smtp,
390 "default": a.is_default,
391 "oauth": a.is_oauth,
392 "selected": i == selected,
393 })
394 })
395 .collect();
396 return serde_json::Value::Array(rows).to_string();
397 }
398 accounts
399 .iter()
400 .enumerate()
401 .map(|(i, a)| {
402 format!(
403 "{}\t{}\t{}\t{}",
404 cell(&a.email),
405 if i == selected { "selected" } else { "-" },
406 if a.is_default { "default" } else { "-" },
407 cell(&a.imap),
408 )
409 })
410 .collect::<Vec<_>>()
411 .join("\n")
412 }
413
414 /// `folders`, with per-folder counts from the cached messages.
415 pub fn render_folders(folders: &[FolderInfo], emails: &[Email], current: &str, json: bool) -> String {
416 let counts = |tag: &str| {
417 let total = emails.iter().filter(|e| e.folder == tag).count();
418 let unread = emails.iter().filter(|e| e.folder == tag && !e.read).count();
419 (total, unread)
420 };
421 if json {
422 let rows: Vec<serde_json::Value> = folders
423 .iter()
424 .map(|f| {
425 let (total, unread) = counts(&f.tag);
426 serde_json::json!({
427 "tag": f.tag,
428 "label": f.label,
429 "mailbox": if f.mailbox.is_empty() { serde_json::Value::Null } else { f.mailbox.clone().into() },
430 "current": f.tag == current,
431 "messages": total,
432 "unread": unread,
433 })
434 })
435 .collect();
436 return serde_json::Value::Array(rows).to_string();
437 }
438 folders
439 .iter()
440 .map(|f| {
441 let (total, unread) = counts(&f.tag);
442 format!(
443 "{}\t{}\t{}\t{}\t{}\t{}",
444 cell(&f.tag),
445 cell(&f.label),
446 if f.mailbox.is_empty() { "(local)".to_string() } else { cell(&f.mailbox) },
447 if f.tag == current { "current" } else { "-" },
448 total,
449 unread,
450 )
451 })
452 .collect::<Vec<_>>()
453 .join("\n")
454 }
455
456 /// What `status` reports, gathered by the app.
457 pub struct StatusInfo {
458 pub account: Option<String>,
459 pub accounts: usize,
460 pub folder_tag: String,
461 pub folder_label: String,
462 pub cached: usize,
463 pub in_folder: usize,
464 pub unread_in_folder: usize,
465 pub selected: Option<usize>,
466 pub syncing: bool,
467 pub last_sync_secs_ago: Option<u64>,
468 }
469
470 pub fn render_status(s: &StatusInfo, json: bool) -> String {
471 let socket = cce_ui::ipc::socket_path(PREFIX);
472 if json {
473 return serde_json::json!({
474 "account": s.account,
475 "accounts": s.accounts,
476 "folder": s.folder_tag,
477 "folder_label": s.folder_label,
478 "cached": s.cached,
479 "in_folder": s.in_folder,
480 "unread_in_folder": s.unread_in_folder,
481 "selected": s.selected,
482 "syncing": s.syncing,
483 "last_sync_secs_ago": s.last_sync_secs_ago,
484 "socket": socket,
485 })
486 .to_string();
487 }
488 let last = match s.last_sync_secs_ago {
489 Some(secs) => format!("last started {secs}s ago"),
490 None => "never".to_string(),
491 };
492 format!(
493 "account: {} ({} configured)\nfolder: {} ({})\nmessages: {} cached, {} in folder, {} unread\nselected: {}\nsync: {}, {}\nsocket: {}",
494 s.account.as_deref().unwrap_or("(none)"),
495 s.accounts,
496 s.folder_tag,
497 s.folder_label,
498 s.cached,
499 s.in_folder,
500 s.unread_in_folder,
501 s.selected.map(|id| id.to_string()).unwrap_or_else(|| "none".to_string()),
502 if s.syncing { "in flight" } else { "idle" },
503 last,
504 socket,
505 )
506 }
507
508 #[cfg(test)]
509 mod tests {
510 use super::*;
511
512 #[test]
513 fn commands_parse() {
514 assert_eq!(parse("help").unwrap().command, Command::Help);
515 assert_eq!(
516 parse("list").unwrap().command,
517 Command::List { folder: None, limit: DEFAULT_LIMIT, unread: false }
518 );
519 assert_eq!(
520 parse("--json list All Mail --limit 5 --unread").unwrap(),
521 Parsed {
522 command: Command::List { folder: Some("All Mail".into()), limit: 5, unread: true },
523 json: true
524 }
525 );
526 assert_eq!(
527 parse("search invoice due -n 3").unwrap().command,
528 Command::Search { query: "invoice due".into(), limit: 3 }
529 );
530 assert_eq!(parse("get 42").unwrap().command, Command::Get(42));
531 assert_eq!(parse("mark-unread 7").unwrap().command, Command::MarkRead(7, false));
532 assert_eq!(
533 parse("select-account a@b.c").unwrap().command,
534 Command::SelectAccount("a@b.c".into())
535 );
536 assert_eq!(parse("switch-folder sent").unwrap().command, Command::SwitchFolder("sent".into()));
537 assert_eq!(
538 parse("compose mailto:x@y.z?subject=hi").unwrap().command,
539 Command::Compose(Some("mailto:x@y.z?subject=hi".into()))
540 );
541 assert_eq!(parse("quit").unwrap().command, Command::Quit);
542 }
543
544 #[test]
545 fn bad_lines_are_errors() {
546 assert!(parse("").is_err());
547 assert!(parse("bogus").is_err());
548 assert!(parse("get").is_err());
549 assert!(parse("get x").is_err());
550 assert!(parse("get 1 2").is_err());
551 assert!(parse("search").is_err());
552 assert!(parse("list --limit").is_err());
553 assert!(parse("list --limit many").is_err());
554 assert!(parse("status --verbose").is_err());
555 assert!(parse("sync now").is_err());
556 }
557
558 fn email(id: usize, subject: &str, read: bool) -> Email {
559 Email {
560 id,
561 from: "a@b.c".into(),
562 to: "me@x.y".into(),
563 subject: subject.into(),
564 body: "hello\nworld".into(),
565 date: "2026-09-24".into(),
566 read,
567 folder: "inbox".into(),
568 cc: String::new(),
569 bcc: String::new(),
570 attachments: vec!["/tmp/dir/report.pdf".into()],
571 remote_attachments: Vec::new(),
572 uid: Some(id as u32),
573 ts: Some(1_000 + id as i64),
574 origin_folder: None,
575 }
576 }
577
578 #[test]
579 fn rows_are_tab_separated_and_sanitized() {
580 let e = email(3, "tabs\tand\nnewlines", false);
581 let text = render_rows(&[&e], false);
582 assert_eq!(text, "3\tunread\tinbox\t2026-09-24\ta@b.c\ttabs and newlines");
583 let json: serde_json::Value = serde_json::from_str(&render_rows(&[&e], true)).unwrap();
584 assert_eq!(json[0]["id"], 3);
585 assert_eq!(json[0]["attachments"][0], "report.pdf");
586 assert_eq!(render_rows(&[], false), "");
587 assert_eq!(render_rows(&[], true), "[]");
588 }
589
590 #[test]
591 fn email_text_has_headers_then_body() {
592 let e = email(1, "Hi", true);
593 let text = render_email(&e, false);
594 assert!(text.starts_with("id: 1\nfolder: inbox\nfrom: a@b.c\n"));
595 assert!(text.contains("\nread: yes\nattachments: report.pdf\n\nhello\nworld"));
596 let json: serde_json::Value = serde_json::from_str(&render_email(&e, true)).unwrap();
597 assert_eq!(json["body"], "hello\nworld");
598 }
599
600 #[test]
601 fn accounts_never_leak_secrets() {
602 let acc = AccountInfo {
603 email: "me@x.y".into(),
604 imap: "imap.x.y:993".into(),
605 smtp: "smtp.x.y:587".into(),
606 is_default: true,
607 password: "hunter2".into(),
608 is_oauth: true,
609 access_token: Some("tok".into()),
610 refresh_token: Some("ref".into()),
611 token_expiry: None,
612 client_id: Some("cid".into()),
613 client_secret: Some("csec".into()),
614 keyring_backed: false,
615 };
616 for json in [false, true] {
617 let out = render_accounts(&[acc.clone()], 0, json);
618 for secret in ["hunter2", "tok", "ref", "csec"] {
619 assert!(!out.contains(secret), "{out}");
620 }
621 assert!(out.contains("me@x.y"));
622 }
623 }
624 }