aboutsummaryrefslogtreecommitdiffstats
path: root/mail-overview
diff options
context:
space:
mode:
Diffstat (limited to 'mail-overview')
-rw-r--r--mail-overview/Accounts.qml204
-rw-r--r--mail-overview/shell.qml13
2 files changed, 217 insertions, 0 deletions
diff --git a/mail-overview/Accounts.qml b/mail-overview/Accounts.qml
new file mode 100644
index 0000000..1bb451f
--- /dev/null
+++ b/mail-overview/Accounts.qml
@@ -0,0 +1,204 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+// Which accounts exist, how much unread mail each has, and what the newest of
+// it is.
+//
+// The account list is not here. qtmaildir.conf already carries one
+// [account.<key>] section per account, where <key> is exactly the suffix of
+// the notmuch tag account-<key>, plus a short label and a colour. Parsing that
+// means adding an account to qtmaildir makes it appear here with no edit.
+Singleton {
+ id: root
+
+ readonly property string config: `${Quickshell.env("HOME")}/.config/qtmaildir/qtmaildir.conf`
+
+ // Counting by the account-* tag rather than by a path glob is deliberate.
+ // notmuch deduplicates by message id, so a message that arrived at two of
+ // the configured addresses is one message with two paths: a path glob
+ // counts it under both accounts and the rows then sum to more than the
+ // total the waybar icon shows. The tag is a property of the message, so
+ // it is singular and the rows always sum to the header.
+ readonly property string scope: "tag:unread and tag:inbox"
+
+ // [{ key, label, color, count, threads: [{ authors, date, subject }] }]
+ // count is -1 until known, and stays -1 on failure: a zero there would
+ // read as an empty inbox.
+ property var accounts: []
+
+ property string error: ""
+ property bool loading: false
+
+ readonly property int total:
+ accounts.reduce((sum, a) => sum + Math.max(a.count, 0), 0)
+
+ readonly property bool anyUnknown: accounts.some(a => a.count < 0)
+
+ // The config is watched, so adding an account in qtmaildir updates this
+ // list without restarting the shell.
+ FileView {
+ path: root.config
+ watchChanges: true
+ onFileChanged: reload()
+ onLoadFailed: {
+ root.accounts = [];
+ root.error = "cannot read qtmaildir.conf";
+ }
+ onLoaded: {
+ root.accounts = root.parseAccounts(text());
+ root.error = root.accounts.length ? "" : "no accounts in qtmaildir.conf";
+ root.refresh();
+ }
+ }
+
+ // Sections in file order, which is the display order.
+ function parseAccounts(conf) {
+ const out = [];
+ // QML's JS engine has no String.matchAll, so this is an exec loop.
+ // At least one key contains a dot of its own, so the key runs to the
+ // closing bracket rather than to the first dot.
+ const re = /^\[account\.([^\]]+)\]([^\[]*)/gm;
+ let m;
+ while ((m = re.exec(conf)) !== null) {
+ const key = m[1];
+ const body = m[2];
+ const field = name => {
+ const f = body.match(new RegExp(`^\\s*${name}\\s*=\\s*(.+)$`, "m"));
+ return f ? f[1].trim() : "";
+ };
+ out.push({
+ key: key,
+ label: field("label") || key,
+ color: field("color"),
+ count: -1,
+ threads: [],
+ });
+ }
+ return out;
+ }
+
+ function refresh() {
+ if (!accounts.length) return;
+ loading = true;
+ proc.next = 0;
+ proc.loadNext();
+ }
+
+ // One process per account, in turn, fetching the count and the newest
+ // three threads together and splitting on a marker: two calls are needed
+ // because a search limited to three rows cannot report the total, and
+ // doing them as one command keeps the pair consistent.
+ Process {
+ id: proc
+ property int next: 0
+ property int current: 0
+
+ function loadNext() {
+ if (next >= root.accounts.length) {
+ root.loading = false;
+ return;
+ }
+ current = next;
+ next++;
+ const q = `${root.scope} and tag:account-${root.accounts[current].key}`;
+ command = ["sh", "-c",
+ `notmuch count ${JSON.stringify(q)}; ` +
+ `echo '===SPLIT==='; ` +
+ `notmuch search --format=json --limit=3 --sort=newest-first ${JSON.stringify(q)}`];
+ // Assigning true to an already-true `running` does nothing, and
+ // this Process is reused for every account in turn.
+ running = false;
+ running = true;
+ }
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.applyResult(proc.current, text);
+ proc.loadNext();
+ }
+ }
+
+ onExited: code => {
+ // A non-zero exit leaves this account's count at whatever it was,
+ // which for a first load is -1 and renders as a dash. Carrying on
+ // to the next account matters: one failure must not blank the
+ // whole panel.
+ if (code !== 0) proc.loadNext();
+ }
+ }
+
+ function applyResult(index, out) {
+ const parts = out.split("===SPLIT===");
+ if (parts.length < 2) return;
+
+ const next = accounts.slice();
+ const acct = Object.assign({}, next[index]);
+
+ // The output is the test, not the exit status: a query notmuch rejects
+ // prints nothing, and an empty string must not become a zero, which
+ // would read as "no new mail".
+ const n = parts[0].trim();
+ acct.count = /^\d+$/.test(n) ? parseInt(n, 10) : -1;
+
+ acct.threads = [];
+ try {
+ const rows = JSON.parse(parts[1]);
+ if (Array.isArray(rows)) {
+ acct.threads = rows.map(r => ({
+ authors: String(r.authors ?? ""),
+ date: String(r.date_relative ?? ""),
+ subject: String(r.subject ?? "(no subject)"),
+ }));
+ }
+ } catch (e) {
+ // Keep the count, drop the thread list: a count with no preview is
+ // still useful, and an empty search is a legitimate "[]".
+ }
+
+ next[index] = acct;
+ accounts = next;
+ }
+
+ // Launching qtmaildir, and syncing. qtmaildir takes no arguments, so
+ // there is nothing to tell it about the account or thread clicked.
+ Process { id: openProc; command: [`${Quickshell.env("HOME")}/bin/qtmaildir`] }
+
+ function openClient() {
+ openProc.running = false;
+ openProc.running = true;
+ }
+
+ property bool syncing: false
+
+ // mailsync.sh is already lock-protected against a concurrent cron run, so
+ // this does not need its own guard beyond not stacking clicks.
+ Process {
+ id: syncProc
+ command: [`${Quickshell.env("HOME")}/bin/mailsync.sh`]
+ onExited: {
+ root.syncing = false;
+ root.refresh();
+ }
+ }
+
+ function sync() {
+ if (syncing) return;
+ syncing = true;
+ syncProc.running = false;
+ syncProc.running = true;
+ }
+}
diff --git a/mail-overview/shell.qml b/mail-overview/shell.qml
index cd2e907..1925f44 100644
--- a/mail-overview/shell.qml
+++ b/mail-overview/shell.qml
@@ -21,4 +21,17 @@ ShellRoot {
function show() { panel.show(); }
function close() { panel.close(); }
}
+
+ // TEMPORARY probe for Task 3 verification. Removed in Step 5.
+ Component.onCompleted: Qt.callLater(() => {
+ console.log("ACCOUNTS", JSON.stringify(Accounts.accounts.map(a => a.key + ":" + a.label)));
+ })
+ Connections {
+ target: Accounts
+ function onLoadingChanged() {
+ if (!Accounts.loading)
+ console.log("COUNTS", JSON.stringify(Accounts.accounts.map(a => a.label + "=" + a.count)),
+ "total", Accounts.total);
+ }
+ }
}