git.lucas.co / cce-mail
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 }