aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/mail/Accounts.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-14 12:27:41 +0200
committerDanilo M. <danix@danix.xyz>2026-09-14 12:27:41 +0200
commitd513418a2e8854bfe8702577cda6db2cbf0ce292 (patch)
tree5e95c58030058ed5d0b986d1e0f25b7e6e005b90 /desktop/modules/mail/Accounts.qml
parentd203d938f005d7542bf13e217ac51917866a8ace (diff)
downloadquickshell-d513418a2e8854bfe8702577cda6db2cbf0ce292.tar.gz
quickshell-d513418a2e8854bfe8702577cda6db2cbf0ce292.zip
feat(desktop): move mail-overview in as the mail module
Accounts.qml is unchanged and MailPanel's body becomes the page. The three scripts move with it, which changes the absolute paths in autostart and in the waybar module; both are outside this repo and listed in the plan's final task. Button.qml, byte-identical here and in vm-manager, lands at the shell root as the single copy.
Diffstat (limited to 'desktop/modules/mail/Accounts.qml')
-rw-r--r--desktop/modules/mail/Accounts.qml195
1 files changed, 195 insertions, 0 deletions
diff --git a/desktop/modules/mail/Accounts.qml b/desktop/modules/mail/Accounts.qml
new file mode 100644
index 0000000..0ae6886
--- /dev/null
+++ b/desktop/modules/mail/Accounts.qml
@@ -0,0 +1,195 @@
+// 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.
+ //
+ // A line walk rather than one regex over the whole file. The obvious
+ // pattern for a section body, "everything up to the next [", is wrong
+ // here: several accounts carry folder names like "[Gmail]/Bozze", so the
+ // body ended before its label and three of five accounts silently fell
+ // back to displaying their key. Walking lines says what it means, and the
+ // only state is which section we are in.
+ function parseAccounts(conf) {
+ const out = [];
+ let cur = null;
+
+ for (const line of conf.split("\n")) {
+ // 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 header = line.match(/^\[account\.([^\]]+)\]/);
+ if (header) {
+ cur = { key: header[1], label: "", color: "", count: -1, threads: [] };
+ out.push(cur);
+ continue;
+ }
+ // Any other section ends the current account.
+ if (line.startsWith("[")) {
+ cur = null;
+ continue;
+ }
+ if (!cur) continue;
+
+ const field = line.match(/^\s*(label|color)\s*=\s*(.+)$/);
+ if (field) cur[field[1]] = field[2].trim();
+ }
+
+ // A missing label shows the key, which is ugly but true.
+ for (const a of out) if (!a.label) a.label = a.key;
+ 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. It 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;
+ }
+}