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

commit21e87f62d64626fc8652db7dd869a96a332d07ec
parent70355f9405
authorLucas Galante <[email protected]>
date2026-09-24 12:34
Control socket: drive and query the window over `cce-mail ctl`

A line-oriented Unix socket at /tmp/cce-mail-<WAYLAND_DISPLAY>.sock, the
same shape as the compositor's, answered by the app on its main thread
from the state the window paints. `cce-mail ctl <command>` is the client;
`cce-mail ctl help` lists the commands: status, accounts, folders, list,
search, get, open, mark-read/unread, select-account, switch-folder, sync,
compose [mailto:], quit. Text replies are tab-separated rows, --json
returns JSON; account rows never carry passwords or tokens.

Actions reuse the widgets' own messages so a driven window behaves like a
clicked one. The mailto: launch prefill and ToggleUnread now go through
the same helpers the socket uses (prefill_compose, set_read).

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/ipc.rs  | 626 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs | 239 ++++++++++++++++++++---
 2 files changed, 835 insertions(+), 30 deletions(-)

diff --git a/src/ipc.rs b/src/ipc.rs
new file mode 100644
index 0000000..14c575d
--- /dev/null
+++ b/src/ipc.rs
@@ -0,0 +1,626 @@
+//! The control socket: `/tmp/cce-mail-<WAYLAND_DISPLAY>.sock`.
+//!
+//! Line-oriented request/reply, the same shape as the compositor's control
+//! socket: a client connects, writes one command line, and reads the reply
+//! to EOF. `cce-mail ctl <command…>` is the CLI client ([`run_client`]);
+//! `cce-mail ctl help` prints [`HELP`], the command list.
+//!
+//! Every command is answered by the app on its main thread, from the same
+//! state the window paints — the listener thread only reads the line and
+//! hands it over as [`AppMessage::Ipc`], carrying the stream so the reply
+//! can be written once the app has one. Nothing here reads the on-disk
+//! cache: it lags the window by a sync, and a reader would still have to
+//! re-derive which account and folder the user is looking at.
+//!
+//! Replies are text by default — one row per line, tab-separated columns,
+//! `error: …` on failure — and JSON with `--json`, for agents and scripts.
+//! Account rows never carry passwords or tokens.
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+use crate::{AccountInfo, AppMessage, Email, FolderInfo};
+
+/// Socket prefix; `cce_ui::ipc::socket_path` appends `-<WAYLAND_DISPLAY>`.
+const PREFIX: &str = "cce-mail";
+
+/// Bound on any one socket read or write. A client that connects and never
+/// sends would otherwise park the listener thread forever, and one that
+/// vanished mid-reply would park the app's main thread.
+const IO_TIMEOUT: Duration = Duration::from_secs(2);
+
+/// Rows `list` and `search` return when no `--limit` is given.
+pub const DEFAULT_LIMIT: usize = 50;
+
+/// The socket path this process bound (and must unlink on exit), if any.
+static OWNED_PATH: Mutex<Option<String>> = Mutex::new(None);
+
+pub const HELP: &str = "\
+cce-mail ctl <command> [--json]
+
+  help                          this list
+  status                        account, folder, counts, sync state
+  accounts                      the configured accounts
+  folders                       the selected account's folders, with counts
+  list [folder] [--limit N] [--unread]
+                                messages in a folder (default: the open one), newest first
+  search <words…> [--limit N]   messages whose from/subject/body contain the words, every folder
+  get <id>                      one message: headers and the cached text body (a preview, up to 1200 chars)
+  open <id>                     show a message in the window (marks it read, like a click)
+  mark-read <id>                set the read flag; mirrored to the server for inbox mail
+  mark-unread <id>
+  select-account <email>
+  switch-folder <folder>        by tag (inbox, sent, drafts, trash, …) or label
+  sync                          start a sync now
+  compose [mailto:…]            open the compose dialog, prefilled from the URL if given
+  quit
+
+Text replies are tab-separated rows (list/search: id, read state, folder,
+date, from, subject); --json returns JSON instead. Message ids are the app's
+own and can change between syncs — take them from a fresh list.";
+
+/// One command line off the socket, plus the stream to answer it on.
+///
+/// Clone-able (the engine's message type must be), so the stream sits behind
+/// an `Arc`; the first [`respond`](Self::respond) takes it and later ones are
+/// no-ops.
+#[derive(Debug, Clone)]
+pub struct Request {
+    pub line: String,
+    stream: Arc<Mutex<Option<UnixStream>>>,
+}
+
+impl Request {
+    /// Write `reply` (newline-terminated) and close the write side, which is
+    /// the EOF the client reads to.
+    pub fn respond(&self, reply: &str) {
+        let Some(mut stream) = self.stream.lock().ok().and_then(|mut s| s.take()) else {
+            return;
+        };
+        let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
+        let _ = stream.write_all(reply.as_bytes());
+        if !reply.ends_with('\n') {
+            let _ = stream.write_all(b"\n");
+        }
+        let _ = stream.shutdown(std::net::Shutdown::Write);
+    }
+}
+
+/// Bind the control socket and serve it on a thread, pushing each received
+/// line into the app's calloop channel as [`AppMessage::Ipc`].
+///
+/// A second instance finds the socket answering and runs without one — the
+/// window still works, it just cannot be driven. A socket file nobody
+/// answers on is a leftover from a crash and is replaced.
+pub fn spawn_listener(sender: calloop::channel::Sender<AppMessage>) {
+    let path = cce_ui::ipc::socket_path(PREFIX);
+    if std::path::Path::new(&path).exists() {
+        if UnixStream::connect(&path).is_ok() {
+            eprintln!("cce-mail: another instance answers on {path}; this one runs without a control socket");
+            return;
+        }
+        let _ = std::fs::remove_file(&path);
+    }
+    let listener = match UnixListener::bind(&path) {
+        Ok(l) => l,
+        Err(e) => {
+            eprintln!("cce-mail: could not bind the control socket {path} ({e})");
+            return;
+        }
+    };
+    *OWNED_PATH.lock().unwrap() = Some(path);
+    std::thread::spawn(move || {
+        for conn in listener.incoming() {
+            let Ok(conn) = conn else { continue };
+            let _ = conn.set_read_timeout(Some(IO_TIMEOUT));
+            let mut reader = BufReader::new(conn);
+            let mut line = String::new();
+            if reader.read_line(&mut line).is_err() {
+                continue;
+            }
+            let req = Request {
+                line: line.trim().to_string(),
+                stream: Arc::new(Mutex::new(Some(reader.into_inner()))),
+            };
+            if sender.send(AppMessage::Ipc(req)).is_err() {
+                return; // channel gone: the app is shutting down
+            }
+        }
+    });
+}
+
+/// Unlink the socket if this process bound it. Called after the engine loop
+/// returns; a crash skips it, which is what the stale-socket replacement in
+/// [`spawn_listener`] exists for.
+pub fn cleanup() {
+    if let Some(path) = OWNED_PATH.lock().unwrap().take() {
+        let _ = std::fs::remove_file(path);
+    }
+}
+
+/// `cce-mail ctl <args…>`: send the line, print the reply, return the exit
+/// code — 0 on success, 1 when the app answered `error:`, 2 when no app
+/// answered at all. `help` (or nothing) prints [`HELP`] without a socket.
+pub fn run_client(args: &[String]) -> i32 {
+    let line = args.join(" ");
+    let line = line.trim();
+    if line.is_empty() || line == "help" {
+        println!("{HELP}");
+        return 0;
+    }
+    match cce_ui::ipc::send_command(PREFIX, line) {
+        Ok(reply) => {
+            print!("{reply}");
+            if reply.starts_with("error:") {
+                1
+            } else {
+                0
+            }
+        }
+        Err(e) => {
+            eprintln!(
+                "error: cce-mail is not reachable at {} ({e})",
+                cce_ui::ipc::socket_path(PREFIX)
+            );
+            2
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Parsing
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Command {
+    Help,
+    Status,
+    Accounts,
+    Folders,
+    List { folder: Option<String>, limit: usize, unread: bool },
+    Search { query: String, limit: usize },
+    Get(usize),
+    Open(usize),
+    MarkRead(usize, bool),
+    SelectAccount(String),
+    SwitchFolder(String),
+    Sync,
+    Compose(Option<String>),
+    Quit,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Parsed {
+    pub command: Command,
+    /// `--json` was given: render the reply as JSON.
+    pub json: bool,
+}
+
+/// Parse one command line. Flags (`--json`, `--unread`, `--limit N`) may
+/// sit anywhere; the remaining words are the command and its arguments.
+pub fn parse(line: &str) -> Result<Parsed, String> {
+    let mut json = false;
+    let mut unread = false;
+    let mut limit = DEFAULT_LIMIT;
+    let mut words: Vec<&str> = Vec::new();
+    let mut it = line.split_whitespace();
+    while let Some(w) = it.next() {
+        match w {
+            "--json" => json = true,
+            "--unread" => unread = true,
+            "--limit" | "-n" => {
+                let v = it.next().ok_or("--limit needs a number")?;
+                limit = v.parse().map_err(|_| format!("bad limit {v:?}"))?;
+            }
+            _ if w.starts_with("--") => return Err(format!("unknown flag {w}")),
+            _ => words.push(w),
+        }
+    }
+    let Some((&cmd, rest)) = words.split_first() else {
+        return Err("empty command (try `help`)".to_string());
+    };
+    let one_id = |rest: &[&str]| -> Result<usize, String> {
+        match rest {
+            [v] => v.parse().map_err(|_| format!("bad message id {v:?}")),
+            _ => Err(format!("{cmd} takes exactly one message id")),
+        }
+    };
+    let no_args = |rest: &[&str], c: Command| -> Result<Command, String> {
+        if rest.is_empty() {
+            Ok(c)
+        } else {
+            Err(format!("{cmd} takes no arguments"))
+        }
+    };
+    let command = match cmd {
+        "help" => no_args(rest, Command::Help)?,
+        "status" => no_args(rest, Command::Status)?,
+        "accounts" => no_args(rest, Command::Accounts)?,
+        "folders" => no_args(rest, Command::Folders)?,
+        "sync" => no_args(rest, Command::Sync)?,
+        "quit" => no_args(rest, Command::Quit)?,
+        // Folder labels can carry spaces ("All Mail"), so the rest is the name.
+        "list" => Command::List {
+            folder: (!rest.is_empty()).then(|| rest.join(" ")),
+            limit,
+            unread,
+        },
+        "search" => {
+            if rest.is_empty() {
+                return Err("search needs at least one word".to_string());
+            }
+            Command::Search { query: rest.join(" "), limit }
+        }
+        "get" => Command::Get(one_id(rest)?),
+        "open" => Command::Open(one_id(rest)?),
+        "mark-read" => Command::MarkRead(one_id(rest)?, true),
+        "mark-unread" => Command::MarkRead(one_id(rest)?, false),
+        "select-account" => match rest {
+            [e] => Command::SelectAccount(e.to_string()),
+            _ => return Err("select-account takes one email address".to_string()),
+        },
+        "switch-folder" => {
+            if rest.is_empty() {
+                return Err("switch-folder needs a folder tag or label".to_string());
+            }
+            Command::SwitchFolder(rest.join(" "))
+        }
+        "compose" => match rest {
+            [] => Command::Compose(None),
+            [u] => Command::Compose(Some(u.to_string())),
+            _ => return Err("compose takes at most one mailto: URL".to_string()),
+        },
+        other => return Err(format!("unknown command {other:?} (try `help`)")),
+    };
+    Ok(Parsed { command, json })
+}
+
+// ---------------------------------------------------------------------------
+// Rendering
+
+pub fn error(msg: &str) -> String {
+    format!("error: {msg}")
+}
+
+/// A success acknowledgement for a command that changes state.
+pub fn ok(what: &str, json: bool) -> String {
+    if json {
+        serde_json::json!({ "ok": what }).to_string()
+    } else {
+        format!("ok: {what}")
+    }
+}
+
+/// One text cell: tabs and newlines would break the row grammar.
+fn cell(s: &str) -> String {
+    s.replace(['\t', '\n', '\r'], " ")
+}
+
+/// Attachment names as the user sees them: what the server reported for
+/// fetched mail, the local file names for drafts and sent copies.
+fn attachment_names(e: &Email) -> Vec<String> {
+    if !e.remote_attachments.is_empty() {
+        e.remote_attachments.iter().map(|a| a.name.clone()).collect()
+    } else {
+        e.attachments
+            .iter()
+            .map(|p| {
+                std::path::Path::new(p)
+                    .file_name()
+                    .map(|n| n.to_string_lossy().into_owned())
+                    .unwrap_or_else(|| p.clone())
+            })
+            .collect()
+    }
+}
+
+fn row_json(e: &Email) -> serde_json::Value {
+    serde_json::json!({
+        "id": e.id,
+        "folder": e.folder,
+        "read": e.read,
+        "date": e.date,
+        "ts": e.ts,
+        "from": e.from,
+        "to": e.to,
+        "subject": e.subject,
+        "attachments": attachment_names(e),
+    })
+}
+
+/// `list` / `search` rows, in the order given (the app's list order: newest first).
+pub fn render_rows(rows: &[&Email], json: bool) -> String {
+    if json {
+        return serde_json::Value::Array(rows.iter().map(|e| row_json(e)).collect()).to_string();
+    }
+    rows.iter()
+        .map(|e| {
+            format!(
+                "{}\t{}\t{}\t{}\t{}\t{}",
+                e.id,
+                if e.read { "read" } else { "unread" },
+                cell(&e.folder),
+                cell(&e.date),
+                cell(&e.from),
+                cell(&e.subject),
+            )
+        })
+        .collect::<Vec<_>>()
+        .join("\n")
+}
+
+/// `get`: the whole record. Text is headers, a blank line, then the body.
+pub fn render_email(e: &Email, json: bool) -> String {
+    if json {
+        return serde_json::to_string(e).unwrap_or_else(|err| error(&err.to_string()));
+    }
+    let mut out = String::new();
+    out.push_str(&format!("id: {}\n", e.id));
+    out.push_str(&format!("folder: {}\n", cell(&e.folder)));
+    out.push_str(&format!("from: {}\n", cell(&e.from)));
+    out.push_str(&format!("to: {}\n", cell(&e.to)));
+    if !e.cc.is_empty() {
+        out.push_str(&format!("cc: {}\n", cell(&e.cc)));
+    }
+    if !e.bcc.is_empty() {
+        out.push_str(&format!("bcc: {}\n", cell(&e.bcc)));
+    }
+    out.push_str(&format!("date: {}\n", cell(&e.date)));
+    out.push_str(&format!("subject: {}\n", cell(&e.subject)));
+    out.push_str(&format!("read: {}\n", if e.read { "yes" } else { "no" }));
+    let names = attachment_names(e);
+    if !names.is_empty() {
+        out.push_str(&format!("attachments: {}\n", cell(&names.join(", "))));
+    }
+    out.push('\n');
+    out.push_str(&e.body);
+    out
+}
+
+/// `accounts`. Never the password, tokens or client secret.
+pub fn render_accounts(accounts: &[AccountInfo], selected: usize, json: bool) -> String {
+    if json {
+        let rows: Vec<serde_json::Value> = accounts
+            .iter()
+            .enumerate()
+            .map(|(i, a)| {
+                serde_json::json!({
+                    "email": a.email,
+                    "imap": a.imap,
+                    "smtp": a.smtp,
+                    "default": a.is_default,
+                    "oauth": a.is_oauth,
+                    "selected": i == selected,
+                })
+            })
+            .collect();
+        return serde_json::Value::Array(rows).to_string();
+    }
+    accounts
+        .iter()
+        .enumerate()
+        .map(|(i, a)| {
+            format!(
+                "{}\t{}\t{}\t{}",
+                cell(&a.email),
+                if i == selected { "selected" } else { "-" },
+                if a.is_default { "default" } else { "-" },
+                cell(&a.imap),
+            )
+        })
+        .collect::<Vec<_>>()
+        .join("\n")
+}
+
+/// `folders`, with per-folder counts from the cached messages.
+pub fn render_folders(folders: &[FolderInfo], emails: &[Email], current: &str, json: bool) -> String {
+    let counts = |tag: &str| {
+        let total = emails.iter().filter(|e| e.folder == tag).count();
+        let unread = emails.iter().filter(|e| e.folder == tag && !e.read).count();
+        (total, unread)
+    };
+    if json {
+        let rows: Vec<serde_json::Value> = folders
+            .iter()
+            .map(|f| {
+                let (total, unread) = counts(&f.tag);
+                serde_json::json!({
+                    "tag": f.tag,
+                    "label": f.label,
+                    "mailbox": if f.mailbox.is_empty() { serde_json::Value::Null } else { f.mailbox.clone().into() },
+                    "current": f.tag == current,
+                    "messages": total,
+                    "unread": unread,
+                })
+            })
+            .collect();
+        return serde_json::Value::Array(rows).to_string();
+    }
+    folders
+        .iter()
+        .map(|f| {
+            let (total, unread) = counts(&f.tag);
+            format!(
+                "{}\t{}\t{}\t{}\t{}\t{}",
+                cell(&f.tag),
+                cell(&f.label),
+                if f.mailbox.is_empty() { "(local)".to_string() } else { cell(&f.mailbox) },
+                if f.tag == current { "current" } else { "-" },
+                total,
+                unread,
+            )
+        })
+        .collect::<Vec<_>>()
+        .join("\n")
+}
+
+/// What `status` reports, gathered by the app.
+pub struct StatusInfo {
+    pub account: Option<String>,
+    pub accounts: usize,
+    pub folder_tag: String,
+    pub folder_label: String,
+    pub cached: usize,
+    pub in_folder: usize,
+    pub unread_in_folder: usize,
+    pub selected: Option<usize>,
+    pub syncing: bool,
+    pub last_sync_secs_ago: Option<u64>,
+}
+
+pub fn render_status(s: &StatusInfo, json: bool) -> String {
+    let socket = cce_ui::ipc::socket_path(PREFIX);
+    if json {
+        return serde_json::json!({
+            "account": s.account,
+            "accounts": s.accounts,
+            "folder": s.folder_tag,
+            "folder_label": s.folder_label,
+            "cached": s.cached,
+            "in_folder": s.in_folder,
+            "unread_in_folder": s.unread_in_folder,
+            "selected": s.selected,
+            "syncing": s.syncing,
+            "last_sync_secs_ago": s.last_sync_secs_ago,
+            "socket": socket,
+        })
+        .to_string();
+    }
+    let last = match s.last_sync_secs_ago {
+        Some(secs) => format!("last started {secs}s ago"),
+        None => "never".to_string(),
+    };
+    format!(
+        "account: {} ({} configured)\nfolder: {} ({})\nmessages: {} cached, {} in folder, {} unread\nselected: {}\nsync: {}, {}\nsocket: {}",
+        s.account.as_deref().unwrap_or("(none)"),
+        s.accounts,
+        s.folder_tag,
+        s.folder_label,
+        s.cached,
+        s.in_folder,
+        s.unread_in_folder,
+        s.selected.map(|id| id.to_string()).unwrap_or_else(|| "none".to_string()),
+        if s.syncing { "in flight" } else { "idle" },
+        last,
+        socket,
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn commands_parse() {
+        assert_eq!(parse("help").unwrap().command, Command::Help);
+        assert_eq!(
+            parse("list").unwrap().command,
+            Command::List { folder: None, limit: DEFAULT_LIMIT, unread: false }
+        );
+        assert_eq!(
+            parse("--json list All Mail --limit 5 --unread").unwrap(),
+            Parsed {
+                command: Command::List { folder: Some("All Mail".into()), limit: 5, unread: true },
+                json: true
+            }
+        );
+        assert_eq!(
+            parse("search invoice due -n 3").unwrap().command,
+            Command::Search { query: "invoice due".into(), limit: 3 }
+        );
+        assert_eq!(parse("get 42").unwrap().command, Command::Get(42));
+        assert_eq!(parse("mark-unread 7").unwrap().command, Command::MarkRead(7, false));
+        assert_eq!(
+            parse("select-account [email protected]").unwrap().command,
+            Command::SelectAccount("[email protected]".into())
+        );
+        assert_eq!(parse("switch-folder sent").unwrap().command, Command::SwitchFolder("sent".into()));
+        assert_eq!(
+            parse("compose mailto:[email protected]?subject=hi").unwrap().command,
+            Command::Compose(Some("mailto:[email protected]?subject=hi".into()))
+        );
+        assert_eq!(parse("quit").unwrap().command, Command::Quit);
+    }
+
+    #[test]
+    fn bad_lines_are_errors() {
+        assert!(parse("").is_err());
+        assert!(parse("bogus").is_err());
+        assert!(parse("get").is_err());
+        assert!(parse("get x").is_err());
+        assert!(parse("get 1 2").is_err());
+        assert!(parse("search").is_err());
+        assert!(parse("list --limit").is_err());
+        assert!(parse("list --limit many").is_err());
+        assert!(parse("status --verbose").is_err());
+        assert!(parse("sync now").is_err());
+    }
+
+    fn email(id: usize, subject: &str, read: bool) -> Email {
+        Email {
+            id,
+            from: "[email protected]".into(),
+            to: "[email protected]".into(),
+            subject: subject.into(),
+            body: "hello\nworld".into(),
+            date: "2026-09-24".into(),
+            read,
+            folder: "inbox".into(),
+            cc: String::new(),
+            bcc: String::new(),
+            attachments: vec!["/tmp/dir/report.pdf".into()],
+            remote_attachments: Vec::new(),
+            uid: Some(id as u32),
+            ts: Some(1_000 + id as i64),
+            origin_folder: None,
+        }
+    }
+
+    #[test]
+    fn rows_are_tab_separated_and_sanitized() {
+        let e = email(3, "tabs\tand\nnewlines", false);
+        let text = render_rows(&[&e], false);
+        assert_eq!(text, "3\tunread\tinbox\t2026-09-24\[email protected]\ttabs and newlines");
+        let json: serde_json::Value = serde_json::from_str(&render_rows(&[&e], true)).unwrap();
+        assert_eq!(json[0]["id"], 3);
+        assert_eq!(json[0]["attachments"][0], "report.pdf");
+        assert_eq!(render_rows(&[], false), "");
+        assert_eq!(render_rows(&[], true), "[]");
+    }
+
+    #[test]
+    fn email_text_has_headers_then_body() {
+        let e = email(1, "Hi", true);
+        let text = render_email(&e, false);
+        assert!(text.starts_with("id: 1\nfolder: inbox\nfrom: [email protected]\n"));
+        assert!(text.contains("\nread: yes\nattachments: report.pdf\n\nhello\nworld"));
+        let json: serde_json::Value = serde_json::from_str(&render_email(&e, true)).unwrap();
+        assert_eq!(json["body"], "hello\nworld");
+    }
+
+    #[test]
+    fn accounts_never_leak_secrets() {
+        let acc = AccountInfo {
+            email: "[email protected]".into(),
+            imap: "imap.x.y:993".into(),
+            smtp: "smtp.x.y:587".into(),
+            is_default: true,
+            password: "hunter2".into(),
+            is_oauth: true,
+            access_token: Some("tok".into()),
+            refresh_token: Some("ref".into()),
+            token_expiry: None,
+            client_id: Some("cid".into()),
+            client_secret: Some("csec".into()),
+            keyring_backed: false,
+        };
+        for json in [false, true] {
+            let out = render_accounts(&[acc.clone()], 0, json);
+            for secret in ["hunter2", "tok", "ref", "csec"] {
+                assert!(!out.contains(secret), "{out}");
+            }
+            assert!(out.contains("[email protected]"));
+        }
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index fd66959..c567c21 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
 /// Embedded WPE WebKit for the HTML mail view — see src/wpe/mod.rs.
 #[cfg(feature = "wpe")]
+mod ipc;
 mod wpe;
 use cce_ui::widget::ScrollRegion;
 use wayland_client::QueueHandle;
@@ -115,6 +116,9 @@ enum AppMessage {
     /// default/OAuth all live there now; this app only reads accounts.json.
     ManageAccounts,
     SyncNow,
+    /// One line from the control socket (`cce-mail ctl …`), answered from
+    /// the app's live state and written back on the request's own stream.
+    Ipc(ipc::Request),
     Quit,
     Status(String),
     /// A failure a user must not miss: shown as a sticky red toast (no
@@ -3998,6 +4002,188 @@ impl<'a> __EmailQuadSink<'a> {
     }
 }
 
+/// The control socket's commands (src/ipc.rs parses and renders; this is
+/// the half that touches state). Actions go through `update` with the same
+/// messages the widgets send, so a driven window behaves exactly as a
+/// clicked one — including the side effects (`open` marks read, switching
+/// folders may start a sync).
+impl ClearEmailApp {
+    /// A folder named the way the switcher shows it or by its tag,
+    /// case-insensitively.
+    fn folder_by_name(&self, name: &str) -> Option<&FolderInfo> {
+        let n = name.trim().to_lowercase();
+        self.folders
+            .iter()
+            .find(|f| f.tag.to_lowercase() == n || f.label.to_lowercase() == n)
+    }
+
+    /// Set one message's read flag, mirrored to the server for inbox mail
+    /// (only inbox uids are known to be the INBOX's). False when no message
+    /// has that id.
+    fn set_read(&mut self, id: usize, read: bool) -> bool {
+        let mut push = None;
+        let Some(email) = self.emails.iter_mut().find(|e| e.id == id) else {
+            return false;
+        };
+        if email.read != read {
+            email.read = read;
+            if email.folder == TAG_INBOX {
+                push = email.uid.map(|u| (u, read));
+            }
+        }
+        if let Some((uid, seen)) = push {
+            if let Some(acc) = self.accounts.get(self.selected_account_idx) {
+                set_seen_on_server(acc.clone(), uid, seen, self.sender.clone());
+            }
+        }
+        self.save_emails();
+        true
+    }
+
+    /// Open the compose dialog with a mailto:'s fields filled in.
+    fn prefill_compose(&mut self, m: &MailtoPrefill) {
+        self.clear_compose();
+        for (tb, v) in [
+            (&mut self.compose_to, &m.to),
+            (&mut self.compose_cc, &m.cc),
+            (&mut self.compose_bcc, &m.bcc),
+            (&mut self.compose_subject, &m.subject),
+            (&mut self.compose_body, &m.body),
+        ] {
+            tb.text = v.clone();
+            tb.edit_buffer = v.clone();
+        }
+        self.compose_title = "New Message".to_string();
+        self.compose_open = true;
+        self.needs_rebuild = true;
+    }
+
+    fn handle_ipc(&mut self, line: &str, needs_rebuild: &mut bool, exit: &mut bool) -> String {
+        use ipc::Command;
+        let ipc::Parsed { command, json } = match ipc::parse(line) {
+            Ok(p) => p,
+            Err(e) => return ipc::error(&e),
+        };
+        match command {
+            Command::Help => ipc::HELP.to_string(),
+            Command::Status => {
+                let folder_label = self
+                    .folders
+                    .iter()
+                    .find(|f| f.tag == self.current_folder)
+                    .map(|f| f.label.clone())
+                    .unwrap_or_default();
+                let in_folder = self.emails.iter().filter(|e| e.folder == self.current_folder);
+                let info = ipc::StatusInfo {
+                    account: self.accounts.get(self.selected_account_idx).map(|a| a.email.clone()),
+                    accounts: self.accounts.len(),
+                    folder_tag: self.current_folder.clone(),
+                    folder_label,
+                    cached: self.emails.len(),
+                    in_folder: in_folder.clone().count(),
+                    unread_in_folder: in_folder.filter(|e| !e.read).count(),
+                    selected: self.selected_email_id,
+                    syncing: self.sync_in_flight.is_some(),
+                    last_sync_secs_ago: self.last_sync_start.map(|t| t.elapsed().as_secs()),
+                };
+                ipc::render_status(&info, json)
+            }
+            Command::Accounts => ipc::render_accounts(&self.accounts, self.selected_account_idx, json),
+            Command::Folders => ipc::render_folders(&self.folders, &self.emails, &self.current_folder, json),
+            Command::List { folder, limit, unread } => {
+                let tag = match folder {
+                    None => self.current_folder.clone(),
+                    Some(name) => match self.folder_by_name(&name) {
+                        Some(f) => f.tag.clone(),
+                        None => return ipc::error(&format!("no folder {name:?} (see `folders`)")),
+                    },
+                };
+                let rows: Vec<&Email> = self
+                    .emails
+                    .iter()
+                    .filter(|e| e.folder == tag && (!unread || !e.read))
+                    .take(limit)
+                    .collect();
+                ipc::render_rows(&rows, json)
+            }
+            Command::Search { query, limit } => {
+                // Same match as the window's search box, across every folder.
+                let q = query.to_lowercase();
+                let rows: Vec<&Email> = self
+                    .emails
+                    .iter()
+                    .filter(|e| {
+                        e.from.to_lowercase().contains(&q)
+                            || e.subject.to_lowercase().contains(&q)
+                            || e.body.to_lowercase().contains(&q)
+                    })
+                    .take(limit)
+                    .collect();
+                ipc::render_rows(&rows, json)
+            }
+            Command::Get(id) => match self.emails.iter().find(|e| e.id == id) {
+                Some(e) => ipc::render_email(e, json),
+                None => ipc::error(&format!("no message with id {id}")),
+            },
+            Command::Open(id) => {
+                let Some(folder) = self.emails.iter().find(|e| e.id == id).map(|e| e.folder.clone()) else {
+                    return ipc::error(&format!("no message with id {id}"));
+                };
+                if folder != self.current_folder {
+                    self.update(AppMessage::SwitchFolder(folder), needs_rebuild, exit);
+                }
+                self.update(AppMessage::SelectEmail(id), needs_rebuild, exit);
+                ipc::ok(&format!("opened {id}"), json)
+            }
+            Command::MarkRead(id, read) => {
+                if !self.set_read(id, read) {
+                    return ipc::error(&format!("no message with id {id}"));
+                }
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+                ipc::ok(&format!("{id} marked {}", if read { "read" } else { "unread" }), json)
+            }
+            Command::SelectAccount(email) => {
+                let wanted = email.to_lowercase();
+                match self.accounts.iter().position(|a| a.email.to_lowercase() == wanted) {
+                    Some(idx) => {
+                        self.update(AppMessage::SelectAccount(idx), needs_rebuild, exit);
+                        ipc::ok(&format!("selected {}", self.accounts[idx].email), json)
+                    }
+                    None => ipc::error(&format!("no account {email:?} (see `accounts`)")),
+                }
+            }
+            Command::SwitchFolder(name) => match self.folder_by_name(&name).map(|f| f.tag.clone()) {
+                Some(tag) => {
+                    self.update(AppMessage::SwitchFolder(tag.clone()), needs_rebuild, exit);
+                    ipc::ok(&format!("switched to {tag}"), json)
+                }
+                None => ipc::error(&format!("no folder {name:?} (see `folders`)")),
+            },
+            Command::Sync => {
+                self.update(AppMessage::SyncNow, needs_rebuild, exit);
+                ipc::ok("sync started", json)
+            }
+            Command::Compose(None) => {
+                self.update(AppMessage::ComposeNew, needs_rebuild, exit);
+                ipc::ok("compose opened", json)
+            }
+            Command::Compose(Some(url)) => match parse_mailto(&url) {
+                Some(m) => {
+                    self.prefill_compose(&m);
+                    *needs_rebuild = true;
+                    ipc::ok("compose opened", json)
+                }
+                None => ipc::error(&format!("{url:?} is not a mailto: URL")),
+            },
+            Command::Quit => {
+                *exit = true;
+                ipc::ok("quitting", json)
+            }
+        }
+    }
+}
+
 impl Application for ClearEmailApp {
     type Message = AppMessage;
 
@@ -4007,6 +4193,7 @@ impl Application for ClearEmailApp {
 
     fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<Self::Message>) -> Self {
         cce_ui::scale::set_scale_factor(1.0);
+        ipc::spawn_listener(_sender.clone());
 
         // The bar: the mail-icon app-menu button (its commands pop as a
         // context menu, see `open_mail_menu`) and two selection dropdowns
@@ -4091,22 +4278,6 @@ impl Application for ClearEmailApp {
         // editing flag selects, so a box left untouched must agree with one
         // the user clicked into.
         let mailto = std::env::args().nth(1).and_then(|a| parse_mailto(&a));
-        let (compose_open, compose_title) = match &mailto {
-            Some(m) => {
-                compose_to.text = m.to.clone();
-                compose_to.edit_buffer = m.to.clone();
-                compose_cc.text = m.cc.clone();
-                compose_cc.edit_buffer = m.cc.clone();
-                compose_bcc.text = m.bcc.clone();
-                compose_bcc.edit_buffer = m.bcc.clone();
-                compose_subject.text = m.subject.clone();
-                compose_subject.edit_buffer = m.subject.clone();
-                compose_body.text = m.body.clone();
-                compose_body.edit_buffer = m.body.clone();
-                (true, "New Message".to_string())
-            }
-            None => (false, String::new()),
-        };
 
 
         let emails = if let Some(acc) = accounts.get(selected_account_idx) {
@@ -4178,8 +4349,8 @@ impl Application for ClearEmailApp {
             split_dragging: false,
             context_menu_actions: Vec::new(),
             search_open: false,
-            compose_open,
-            compose_title,
+            compose_open: false,
+            compose_title: String::new(),
             status_message: None,
             sender: _sender.clone(),
             width: 1000,
@@ -4190,6 +4361,12 @@ impl Application for ClearEmailApp {
             ui_context: UiContext::new(),
         };
 
+        // A mailto: launch opens the compose dialog prefilled — the same
+        // path `compose <url>` on the control socket takes at runtime.
+        if let Some(m) = &mailto {
+            app.prefill_compose(m);
+        }
+
         // The first sync goes through start_sync like every other one, so it
         // gets the same keyring refresh and missing-password guard. It used to
         // call sync_imap inline here — the one sync that bypassed both, and
@@ -4599,19 +4776,9 @@ impl Application for ClearEmailApp {
             }
             AppMessage::ToggleUnread => {
                 if let Some(id) = self.selected_email_id {
-                    let mut push = None;
-                    if let Some(email) = self.emails.iter_mut().find(|e| e.id == id) {
-                        email.read = !email.read;
-                        if email.folder == "inbox" {
-                            push = email.uid.map(|u| (u, email.read));
-                        }
-                    }
-                    if let Some((uid, seen)) = push {
-                        if let Some(acc) = self.accounts.get(self.selected_account_idx) {
-                            set_seen_on_server(acc.clone(), uid, seen, self.sender.clone());
-                        }
+                    if let Some(read) = self.emails.iter().find(|e| e.id == id).map(|e| !e.read) {
+                        self.set_read(id, read);
                     }
-                    self.save_emails();
                 }
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
@@ -4646,6 +4813,10 @@ impl Application for ClearEmailApp {
             AppMessage::SyncNow => {
                 self.start_sync(true);
             }
+            AppMessage::Ipc(req) => {
+                let reply = self.handle_ipc(&req.line, needs_rebuild, exit);
+                req.respond(&reply);
+            }
             AppMessage::Quit => {
                 *exit = true;
             }
@@ -6247,10 +6418,18 @@ impl Application for ClearEmailApp {
 }
 
 fn main() {
+    // `cce-mail ctl <command…>` talks to the running window over its
+    // control socket and exits; see src/ipc.rs for the command list.
+    let args: Vec<String> = std::env::args().skip(1).collect();
+    if args.first().map(String::as_str) == Some("ctl") {
+        std::process::exit(ipc::run_client(&args[1..]));
+    }
+
     let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
     let _guard = rt.enter();
 
     cce_ui::engine::run::<ClearEmailApp>();
+    ipc::cleanup();
 }
 
 #[cfg(test)]