// Copyright (C) 2026 Danilo M. // // 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.] section per account, where is exactly the suffix of // the notmuch tag account-, 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; } }