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 }