git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

src/ipc.rs (22K)

  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::{BufRead, BufReader, 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 _ = conn.set_read_timeout(Some(IO_TIMEOUT));
118             let mut reader = BufReader::new(conn);
119             let mut line = String::new();
120             if reader.read_line(&mut line).is_err() {
121                 continue;
122             }
123             let req = Request {
124                 line: line.trim().to_string(),
125                 stream: Arc::new(Mutex::new(Some(reader.into_inner()))),
126             };
127             if sender.send(AppMessage::Ipc(req)).is_err() {
128                 return; // channel gone: the app is shutting down
129             }
130         }
131     });
132 }
133 
134 /// Unlink the socket if this process bound it. Called after the engine loop
135 /// returns; a crash skips it, which is what the stale-socket replacement in
136 /// [`spawn_listener`] exists for.
137 pub fn cleanup() {
138     if let Some(path) = OWNED_PATH.lock().unwrap().take() {
139         let _ = std::fs::remove_file(path);
140     }
141 }
142 
143 /// `cce-mail ctl <args…>`: send the line, print the reply, return the exit
144 /// code — 0 on success, 1 when the app answered `error:`, 2 when no app
145 /// answered at all. `help` (or nothing) prints [`HELP`] without a socket.
146 pub fn run_client(args: &[String]) -> i32 {
147     let line = args.join(" ");
148     let line = line.trim();
149     if line.is_empty() || line == "help" {
150         println!("{HELP}");
151         return 0;
152     }
153     match cce_ui::ipc::send_command(PREFIX, line) {
154         Ok(reply) => {
155             print!("{reply}");
156             if reply.starts_with("error:") {
157                 1
158             } else {
159                 0
160             }
161         }
162         Err(e) => {
163             eprintln!(
164                 "error: cce-mail is not reachable at {} ({e})",
165                 cce_ui::ipc::socket_path(PREFIX)
166             );
167             2
168         }
169     }
170 }
171 
172 // ---------------------------------------------------------------------------
173 // Parsing
174 
175 #[derive(Debug, Clone, PartialEq)]
176 pub enum Command {
177     Help,
178     Status,
179     Accounts,
180     Folders,
181     List { folder: Option<String>, limit: usize, unread: bool },
182     Search { query: String, limit: usize },
183     Get(usize),
184     Open(usize),
185     MarkRead(usize, bool),
186     SelectAccount(String),
187     SwitchFolder(String),
188     Sync,
189     Compose(Option<String>),
190     Quit,
191 }
192 
193 #[derive(Debug, Clone, PartialEq)]
194 pub struct Parsed {
195     pub command: Command,
196     /// `--json` was given: render the reply as JSON.
197     pub json: bool,
198 }
199 
200 /// Parse one command line. Flags (`--json`, `--unread`, `--limit N`) may
201 /// sit anywhere; the remaining words are the command and its arguments.
202 pub fn parse(line: &str) -> Result<Parsed, String> {
203     let mut json = false;
204     let mut unread = false;
205     let mut limit = DEFAULT_LIMIT;
206     let mut words: Vec<&str> = Vec::new();
207     let mut it = line.split_whitespace();
208     while let Some(w) = it.next() {
209         match w {
210             "--json" => json = true,
211             "--unread" => unread = true,
212             "--limit" | "-n" => {
213                 let v = it.next().ok_or("--limit needs a number")?;
214                 limit = v.parse().map_err(|_| format!("bad limit {v:?}"))?;
215             }
216             _ if w.starts_with("--") => return Err(format!("unknown flag {w}")),
217             _ => words.push(w),
218         }
219     }
220     let Some((&cmd, rest)) = words.split_first() else {
221         return Err("empty command (try `help`)".to_string());
222     };
223     let one_id = |rest: &[&str]| -> Result<usize, String> {
224         match rest {
225             [v] => v.parse().map_err(|_| format!("bad message id {v:?}")),
226             _ => Err(format!("{cmd} takes exactly one message id")),
227         }
228     };
229     let no_args = |rest: &[&str], c: Command| -> Result<Command, String> {
230         if rest.is_empty() {
231             Ok(c)
232         } else {
233             Err(format!("{cmd} takes no arguments"))
234         }
235     };
236     let command = match cmd {
237         "help" => no_args(rest, Command::Help)?,
238         "status" => no_args(rest, Command::Status)?,
239         "accounts" => no_args(rest, Command::Accounts)?,
240         "folders" => no_args(rest, Command::Folders)?,
241         "sync" => no_args(rest, Command::Sync)?,
242         "quit" => no_args(rest, Command::Quit)?,
243         // Folder labels can carry spaces ("All Mail"), so the rest is the name.
244         "list" => Command::List {
245             folder: (!rest.is_empty()).then(|| rest.join(" ")),
246             limit,
247             unread,
248         },
249         "search" => {
250             if rest.is_empty() {
251                 return Err("search needs at least one word".to_string());
252             }
253             Command::Search { query: rest.join(" "), limit }
254         }
255         "get" => Command::Get(one_id(rest)?),
256         "open" => Command::Open(one_id(rest)?),
257         "mark-read" => Command::MarkRead(one_id(rest)?, true),
258         "mark-unread" => Command::MarkRead(one_id(rest)?, false),
259         "select-account" => match rest {
260             [e] => Command::SelectAccount(e.to_string()),
261             _ => return Err("select-account takes one email address".to_string()),
262         },
263         "switch-folder" => {
264             if rest.is_empty() {
265                 return Err("switch-folder needs a folder tag or label".to_string());
266             }
267             Command::SwitchFolder(rest.join(" "))
268         }
269         "compose" => match rest {
270             [] => Command::Compose(None),
271             [u] => Command::Compose(Some(u.to_string())),
272             _ => return Err("compose takes at most one mailto: URL".to_string()),
273         },
274         other => return Err(format!("unknown command {other:?} (try `help`)")),
275     };
276     Ok(Parsed { command, json })
277 }
278 
279 // ---------------------------------------------------------------------------
280 // Rendering
281 
282 pub fn error(msg: &str) -> String {
283     format!("error: {msg}")
284 }
285 
286 /// A success acknowledgement for a command that changes state.
287 pub fn ok(what: &str, json: bool) -> String {
288     if json {
289         serde_json::json!({ "ok": what }).to_string()
290     } else {
291         format!("ok: {what}")
292     }
293 }
294 
295 /// One text cell: tabs and newlines would break the row grammar.
296 fn cell(s: &str) -> String {
297     s.replace(['\t', '\n', '\r'], " ")
298 }
299 
300 /// Attachment names as the user sees them: what the server reported for
301 /// fetched mail, the local file names for drafts and sent copies.
302 fn attachment_names(e: &Email) -> Vec<String> {
303     if !e.remote_attachments.is_empty() {
304         e.remote_attachments.iter().map(|a| a.name.clone()).collect()
305     } else {
306         e.attachments
307             .iter()
308             .map(|p| {
309                 std::path::Path::new(p)
310                     .file_name()
311                     .map(|n| n.to_string_lossy().into_owned())
312                     .unwrap_or_else(|| p.clone())
313             })
314             .collect()
315     }
316 }
317 
318 fn row_json(e: &Email) -> serde_json::Value {
319     serde_json::json!({
320         "id": e.id,
321         "folder": e.folder,
322         "read": e.read,
323         "date": e.date,
324         "ts": e.ts,
325         "from": e.from,
326         "to": e.to,
327         "subject": e.subject,
328         "attachments": attachment_names(e),
329     })
330 }
331 
332 /// `list` / `search` rows, in the order given (the app's list order: newest first).
333 pub fn render_rows(rows: &[&Email], json: bool) -> String {
334     if json {
335         return serde_json::Value::Array(rows.iter().map(|e| row_json(e)).collect()).to_string();
336     }
337     rows.iter()
338         .map(|e| {
339             format!(
340                 "{}\t{}\t{}\t{}\t{}\t{}",
341                 e.id,
342                 if e.read { "read" } else { "unread" },
343                 cell(&e.folder),
344                 cell(&e.date),
345                 cell(&e.from),
346                 cell(&e.subject),
347             )
348         })
349         .collect::<Vec<_>>()
350         .join("\n")
351 }
352 
353 /// `get`: the whole record. Text is headers, a blank line, then the body.
354 pub fn render_email(e: &Email, json: bool) -> String {
355     if json {
356         return serde_json::to_string(e).unwrap_or_else(|err| error(&err.to_string()));
357     }
358     let mut out = String::new();
359     out.push_str(&format!("id: {}\n", e.id));
360     out.push_str(&format!("folder: {}\n", cell(&e.folder)));
361     out.push_str(&format!("from: {}\n", cell(&e.from)));
362     out.push_str(&format!("to: {}\n", cell(&e.to)));
363     if !e.cc.is_empty() {
364         out.push_str(&format!("cc: {}\n", cell(&e.cc)));
365     }
366     if !e.bcc.is_empty() {
367         out.push_str(&format!("bcc: {}\n", cell(&e.bcc)));
368     }
369     out.push_str(&format!("date: {}\n", cell(&e.date)));
370     out.push_str(&format!("subject: {}\n", cell(&e.subject)));
371     out.push_str(&format!("read: {}\n", if e.read { "yes" } else { "no" }));
372     let names = attachment_names(e);
373     if !names.is_empty() {
374         out.push_str(&format!("attachments: {}\n", cell(&names.join(", "))));
375     }
376     out.push('\n');
377     out.push_str(&e.body);
378     out
379 }
380 
381 /// `accounts`. Never the password, tokens or client secret.
382 pub fn render_accounts(accounts: &[AccountInfo], selected: usize, json: bool) -> String {
383     if json {
384         let rows: Vec<serde_json::Value> = accounts
385             .iter()
386             .enumerate()
387             .map(|(i, a)| {
388                 serde_json::json!({
389                     "email": a.email,
390                     "imap": a.imap,
391                     "smtp": a.smtp,
392                     "default": a.is_default,
393                     "oauth": a.is_oauth,
394                     "selected": i == selected,
395                 })
396             })
397             .collect();
398         return serde_json::Value::Array(rows).to_string();
399     }
400     accounts
401         .iter()
402         .enumerate()
403         .map(|(i, a)| {
404             format!(
405                 "{}\t{}\t{}\t{}",
406                 cell(&a.email),
407                 if i == selected { "selected" } else { "-" },
408                 if a.is_default { "default" } else { "-" },
409                 cell(&a.imap),
410             )
411         })
412         .collect::<Vec<_>>()
413         .join("\n")
414 }
415 
416 /// `folders`, with per-folder counts from the cached messages.
417 pub fn render_folders(folders: &[FolderInfo], emails: &[Email], current: &str, json: bool) -> String {
418     let counts = |tag: &str| {
419         let total = emails.iter().filter(|e| e.folder == tag).count();
420         let unread = emails.iter().filter(|e| e.folder == tag && !e.read).count();
421         (total, unread)
422     };
423     if json {
424         let rows: Vec<serde_json::Value> = folders
425             .iter()
426             .map(|f| {
427                 let (total, unread) = counts(&f.tag);
428                 serde_json::json!({
429                     "tag": f.tag,
430                     "label": f.label,
431                     "mailbox": if f.mailbox.is_empty() { serde_json::Value::Null } else { f.mailbox.clone().into() },
432                     "current": f.tag == current,
433                     "messages": total,
434                     "unread": unread,
435                 })
436             })
437             .collect();
438         return serde_json::Value::Array(rows).to_string();
439     }
440     folders
441         .iter()
442         .map(|f| {
443             let (total, unread) = counts(&f.tag);
444             format!(
445                 "{}\t{}\t{}\t{}\t{}\t{}",
446                 cell(&f.tag),
447                 cell(&f.label),
448                 if f.mailbox.is_empty() { "(local)".to_string() } else { cell(&f.mailbox) },
449                 if f.tag == current { "current" } else { "-" },
450                 total,
451                 unread,
452             )
453         })
454         .collect::<Vec<_>>()
455         .join("\n")
456 }
457 
458 /// What `status` reports, gathered by the app.
459 pub struct StatusInfo {
460     pub account: Option<String>,
461     pub accounts: usize,
462     pub folder_tag: String,
463     pub folder_label: String,
464     pub cached: usize,
465     pub in_folder: usize,
466     pub unread_in_folder: usize,
467     pub selected: Option<usize>,
468     pub syncing: bool,
469     pub last_sync_secs_ago: Option<u64>,
470 }
471 
472 pub fn render_status(s: &StatusInfo, json: bool) -> String {
473     let socket = cce_ui::ipc::socket_path(PREFIX);
474     if json {
475         return serde_json::json!({
476             "account": s.account,
477             "accounts": s.accounts,
478             "folder": s.folder_tag,
479             "folder_label": s.folder_label,
480             "cached": s.cached,
481             "in_folder": s.in_folder,
482             "unread_in_folder": s.unread_in_folder,
483             "selected": s.selected,
484             "syncing": s.syncing,
485             "last_sync_secs_ago": s.last_sync_secs_ago,
486             "socket": socket,
487         })
488         .to_string();
489     }
490     let last = match s.last_sync_secs_ago {
491         Some(secs) => format!("last started {secs}s ago"),
492         None => "never".to_string(),
493     };
494     format!(
495         "account: {} ({} configured)\nfolder: {} ({})\nmessages: {} cached, {} in folder, {} unread\nselected: {}\nsync: {}, {}\nsocket: {}",
496         s.account.as_deref().unwrap_or("(none)"),
497         s.accounts,
498         s.folder_tag,
499         s.folder_label,
500         s.cached,
501         s.in_folder,
502         s.unread_in_folder,
503         s.selected.map(|id| id.to_string()).unwrap_or_else(|| "none".to_string()),
504         if s.syncing { "in flight" } else { "idle" },
505         last,
506         socket,
507     )
508 }
509 
510 #[cfg(test)]
511 mod tests {
512     use super::*;
513 
514     #[test]
515     fn commands_parse() {
516         assert_eq!(parse("help").unwrap().command, Command::Help);
517         assert_eq!(
518             parse("list").unwrap().command,
519             Command::List { folder: None, limit: DEFAULT_LIMIT, unread: false }
520         );
521         assert_eq!(
522             parse("--json list All Mail --limit 5 --unread").unwrap(),
523             Parsed {
524                 command: Command::List { folder: Some("All Mail".into()), limit: 5, unread: true },
525                 json: true
526             }
527         );
528         assert_eq!(
529             parse("search invoice due -n 3").unwrap().command,
530             Command::Search { query: "invoice due".into(), limit: 3 }
531         );
532         assert_eq!(parse("get 42").unwrap().command, Command::Get(42));
533         assert_eq!(parse("mark-unread 7").unwrap().command, Command::MarkRead(7, false));
534         assert_eq!(
535             parse("select-account [email protected]").unwrap().command,
536             Command::SelectAccount("[email protected]".into())
537         );
538         assert_eq!(parse("switch-folder sent").unwrap().command, Command::SwitchFolder("sent".into()));
539         assert_eq!(
540             parse("compose mailto:[email protected]?subject=hi").unwrap().command,
541             Command::Compose(Some("mailto:[email protected]?subject=hi".into()))
542         );
543         assert_eq!(parse("quit").unwrap().command, Command::Quit);
544     }
545 
546     #[test]
547     fn bad_lines_are_errors() {
548         assert!(parse("").is_err());
549         assert!(parse("bogus").is_err());
550         assert!(parse("get").is_err());
551         assert!(parse("get x").is_err());
552         assert!(parse("get 1 2").is_err());
553         assert!(parse("search").is_err());
554         assert!(parse("list --limit").is_err());
555         assert!(parse("list --limit many").is_err());
556         assert!(parse("status --verbose").is_err());
557         assert!(parse("sync now").is_err());
558     }
559 
560     fn email(id: usize, subject: &str, read: bool) -> Email {
561         Email {
562             id,
563             from: "[email protected]".into(),
564             to: "[email protected]".into(),
565             subject: subject.into(),
566             body: "hello\nworld".into(),
567             date: "2026-09-24".into(),
568             read,
569             folder: "inbox".into(),
570             cc: String::new(),
571             bcc: String::new(),
572             attachments: vec!["/tmp/dir/report.pdf".into()],
573             remote_attachments: Vec::new(),
574             uid: Some(id as u32),
575             ts: Some(1_000 + id as i64),
576             origin_folder: None,
577         }
578     }
579 
580     #[test]
581     fn rows_are_tab_separated_and_sanitized() {
582         let e = email(3, "tabs\tand\nnewlines", false);
583         let text = render_rows(&[&e], false);
584         assert_eq!(text, "3\tunread\tinbox\t2026-09-24\[email protected]\ttabs and newlines");
585         let json: serde_json::Value = serde_json::from_str(&render_rows(&[&e], true)).unwrap();
586         assert_eq!(json[0]["id"], 3);
587         assert_eq!(json[0]["attachments"][0], "report.pdf");
588         assert_eq!(render_rows(&[], false), "");
589         assert_eq!(render_rows(&[], true), "[]");
590     }
591 
592     #[test]
593     fn email_text_has_headers_then_body() {
594         let e = email(1, "Hi", true);
595         let text = render_email(&e, false);
596         assert!(text.starts_with("id: 1\nfolder: inbox\nfrom: [email protected]\n"));
597         assert!(text.contains("\nread: yes\nattachments: report.pdf\n\nhello\nworld"));
598         let json: serde_json::Value = serde_json::from_str(&render_email(&e, true)).unwrap();
599         assert_eq!(json["body"], "hello\nworld");
600     }
601 
602     #[test]
603     fn accounts_never_leak_secrets() {
604         let acc = AccountInfo {
605             email: "[email protected]".into(),
606             imap: "imap.x.y:993".into(),
607             smtp: "smtp.x.y:587".into(),
608             is_default: true,
609             password: "hunter2".into(),
610             is_oauth: true,
611             access_token: Some("tok".into()),
612             refresh_token: Some("ref".into()),
613             token_expiry: None,
614             client_id: Some("cid".into()),
615             client_secret: Some("csec".into()),
616             keyring_backed: false,
617         };
618         for json in [false, true] {
619             let out = render_accounts(&[acc.clone()], 0, json);
620             for secret in ["hunter2", "tok", "ref", "csec"] {
621                 assert!(!out.contains(secret), "{out}");
622             }
623             assert!(out.contains("[email protected]"));
624         }
625     }
626 }