aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 11:08:49 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 11:08:49 +0200
commitc6fc5bbef45bcfa4f8d5e50ca7401aa1f5b6bc7d (patch)
tree775fa89b284c68d30205c58d83fae55336676f60
parent8e9d8b9ac1b593032dea9eca312fa1495c0c8858 (diff)
downloadquickshell-c6fc5bbef45bcfa4f8d5e50ca7401aa1f5b6bc7d.tar.gz
quickshell-c6fc5bbef45bcfa4f8d5e50ca7401aa1f5b6bc7d.zip
settings: qtmaildir's config, in four Mail pages
qtmaildir is configured through qtmaildir.conf alone, which it reads at startup and never writes. Four settings pages under a new Mail group edit it: Mail ([general], [sync], [completion]), Compose, Accounts (a card per account, add and remove) and Tags & shortcuts ([tagcolors] and [keys]). Fields save on Enter and unset when emptied, with qtmaildir's defaults as placeholders and numbers range-checked; the pages say when qtmaildir is running and a change waits for its restart. Ini.js edits the file line by line so its comments survive. Headers are only whole [name] lines: a section is never cut at the next "[", since values such as [Gmail]/Bozze hold brackets, the trap AGENTS.md records. The shortcut actions are copied from qtmaildir's KeyMap::knownActions() rather than exported by it, since a CLI flag there would have meant translations and a changelog entry; the field also takes free text, and the copy is marked as a ponytail ceiling. The settings sidebar now clips, which a list long enough to scroll showed it did not: entries drew over the search box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--AGENTS.md2
-rw-r--r--desktop/README.md1
-rw-r--r--desktop/Settings.qml2
-rw-r--r--desktop/modules/qtmaildir/AccountsPage.qml129
-rw-r--r--desktop/modules/qtmaildir/BoolSetting.qml53
-rw-r--r--desktop/modules/qtmaildir/ChoiceSetting.qml75
-rw-r--r--desktop/modules/qtmaildir/ComposePage.qml33
-rw-r--r--desktop/modules/qtmaildir/GeneralPage.qml68
-rw-r--r--desktop/modules/qtmaildir/Heading.qml20
-rw-r--r--desktop/modules/qtmaildir/Ini.js138
-rw-r--r--desktop/modules/qtmaildir/MailAccountsModule.qml35
-rw-r--r--desktop/modules/qtmaildir/MailComposeModule.qml36
-rw-r--r--desktop/modules/qtmaildir/MailGeneralModule.qml46
-rw-r--r--desktop/modules/qtmaildir/MailTagsKeysModule.qml34
-rw-r--r--desktop/modules/qtmaildir/Note.qml35
-rw-r--r--desktop/modules/qtmaildir/Qtm.qml77
-rw-r--r--desktop/modules/qtmaildir/README.md38
-rw-r--r--desktop/modules/qtmaildir/Setting.qml104
-rw-r--r--desktop/modules/qtmaildir/TagsKeysPage.qml213
-rw-r--r--desktop/modules/qtmaildir/test_ini.js93
-rw-r--r--desktop/shell.qml5
21 files changed, 1236 insertions, 1 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 79b8916..a75748b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,7 +15,7 @@ modules.
performance, clipboard, updates; and the settings
window for pages set once: wallpaper, theme, icons,
sunset, idle (the former appearance shell), autostart,
- the assistant's daemon and presets, git
+ the assistant's daemon and presets, qtmaildir, git
island/ top-centre pills on DP-1: clock, volume, tray, layout, privacy,
calendar
assistant/ the assistant canvas on SUPER+Home, a view over desktop-assistant
diff --git a/desktop/README.md b/desktop/README.md
index 59983a9..40b5e92 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -69,6 +69,7 @@ and flashes it, so a page needs no highlight code of its own.
modules/git/ the global git config, in the settings window
modules/autostart/ Hyprland's autostart.lua, in the settings window
modules/assistant/ desktop-assistant's config, tools, and Ask presets
+ modules/qtmaildir/ qtmaildir.conf: general, compose, accounts, tags and keys
Sound, mail, vm, network, bluetooth, kdeconnect and ai each carry their own README.
So does appearance, for its five settings pages.
diff --git a/desktop/Settings.qml b/desktop/Settings.qml
index dd376c7..745d6c4 100644
--- a/desktop/Settings.qml
+++ b/desktop/Settings.qml
@@ -170,6 +170,8 @@ Scope {
anchors { top: search.bottom; left: parent.left; bottom: parent.bottom; margins: 12 }
width: root.sideWidth
spacing: 4
+ // Without it a scrolled list draws over the search box.
+ clip: true
model: root.found
// A group's heading above its first entry, then the entry.
diff --git a/desktop/modules/qtmaildir/AccountsPage.qml b/desktop/modules/qtmaildir/AccountsPage.qml
new file mode 100644
index 0000000..2f82154
--- /dev/null
+++ b/desktop/modules/qtmaildir/AccountsPage.qml
@@ -0,0 +1,129 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// One card per [account.<key>]: every key qtmaildir reads for an account.
+// Add appends a section with the folders most servers use; Remove asks once
+// and deletes the section. The key itself is the account's identity and is
+// not edited here.
+Column {
+ id: page
+ spacing: 10
+ Note {}
+
+ property string confirming: ""
+ property string newKey: ""
+ readonly property bool keyOk: /^[A-Za-z0-9._-]+$/.test(page.newKey) && !Qtm.accounts.includes(page.newKey)
+
+ Repeater {
+ model: Qtm.accounts
+
+ delegate: Rectangle {
+ id: card
+ required property string modelData
+ readonly property string sec: "account." + modelData
+ objectName: "account:" + modelData
+ width: page.width
+ height: body.implicitHeight + 24
+ radius: 10
+ color: Qt.alpha(Theme.surface, 0.35)
+
+ Column {
+ id: body
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 }
+ spacing: 6
+
+ Item {
+ width: parent.width
+ height: 32
+ Rectangle {
+ id: swatch
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 14; height: 14; radius: 4
+ color: Qtm.get(card.sec, "color") || Theme.overlay
+ }
+ Text {
+ anchors { left: swatch.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: card.modelData
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
+ }
+ Button {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ danger: true
+ text: page.confirming === card.modelData ? "Confirm" : "Remove"
+ onClicked: {
+ if (page.confirming === card.modelData) { page.confirming = ""; Qtm.removeAccount(card.modelData); }
+ else page.confirming = card.modelData;
+ }
+ }
+ }
+
+ Setting { section: card.sec; key: "name"; label: "Name" }
+ Setting { section: card.sec; key: "address"; label: "Address" }
+ Setting { section: card.sec; key: "maildir"; label: "Maildir"; hint: "Relative to notmuch's mail root." }
+ Setting { section: card.sec; key: "inbox"; label: "Inbox"; placeholder: "Inbox" }
+ Setting { section: card.sec; key: "sent"; label: "Sent"; placeholder: "unset: no Sent button" }
+ Setting { section: card.sec; key: "drafts"; label: "Drafts"; placeholder: "unset: no Drafts button" }
+ Setting { section: card.sec; key: "trash"; label: "Trash"; placeholder: "required for Delete" }
+ Setting { section: card.sec; key: "spam"; label: "Spam"; placeholder: "required for Mark spam" }
+ Setting { section: card.sec; key: "send_command"; label: "Send command"; placeholder: "unset: receive only"
+ hint: "Run without a shell: an absolute path and plain arguments, e.g. /usr/bin/msmtp -a work -t." }
+ Setting { section: card.sec; key: "label"; label: "Chip label"; placeholder: card.modelData }
+ Setting { section: card.sec; key: "color"; label: "Chip colour"; placeholder: "generated"
+ hint: "#rrggbb" }
+ Setting { section: card.sec; key: "channel"; label: "mbsync channel"; placeholder: card.modelData }
+ }
+ }
+ }
+
+ Heading { text: "Add an account" }
+ Item {
+ width: page.width
+ height: 34
+ Rectangle {
+ anchors { left: parent.left; right: addBtn.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ text: page.newKey
+ onTextEdited: page.newKey = text
+ onAccepted: addBtn.clicked(null)
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: "account key: letters, digits, dot, dash, underscore"
+ color: Theme.overlay
+ font: parent.font
+ }
+ }
+ }
+ Button {
+ id: addBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Add"
+ enabled: page.keyOk
+ onClicked: {
+ if (!page.keyOk) return;
+ Qtm.addAccount(page.newKey, [["maildir", page.newKey], ["trash", "Trash"], ["spam", "Spam"]]);
+ page.newKey = "";
+ }
+ }
+ }
+}
diff --git a/desktop/modules/qtmaildir/BoolSetting.qml b/desktop/modules/qtmaildir/BoolSetting.qml
new file mode 100644
index 0000000..f5f8feb
--- /dev/null
+++ b/desktop/modules/qtmaildir/BoolSetting.qml
@@ -0,0 +1,53 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// A true/false key, as a switch showing the effective value: the file's, else
+// the default.
+Item {
+ id: s
+ property string section
+ property string key
+ property string label
+ property bool fallback: false
+ property string hint: ""
+ objectName: section + "/" + key
+ width: parent ? parent.width : 0
+ height: 34
+
+ readonly property var raw: Qtm.get(s.section, s.key)
+ readonly property bool on: s.raw === undefined ? s.fallback : s.raw === "true"
+
+ Text {
+ id: lbl
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 160
+ elide: Text.ElideRight
+ text: s.label
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Switch {
+ id: sw
+ anchors { left: lbl.right; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ checked: s.on
+ onToggled: Qtm.set(s.section, s.key, checked ? "true" : "false")
+ }
+ Text {
+ anchors { left: sw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
+ elide: Text.ElideRight
+ text: s.hint
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ }
+}
diff --git a/desktop/modules/qtmaildir/ChoiceSetting.qml b/desktop/modules/qtmaildir/ChoiceSetting.qml
new file mode 100644
index 0000000..01de9da
--- /dev/null
+++ b/desktop/modules/qtmaildir/ChoiceSetting.qml
@@ -0,0 +1,75 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// A key with a few allowed values, as chips. The first chip, "Default",
+// unsets the key; the default it stands for is in its label.
+Column {
+ id: s
+ property string section
+ property string key
+ property string label
+ // [{value, label}]
+ property var options: []
+ property string fallbackLabel: "Default"
+ objectName: section + "/" + key
+ width: parent ? parent.width : 0
+
+ readonly property string value: Qtm.get(s.section, s.key) ?? ""
+
+ Item {
+ width: s.width
+ height: Math.max(34, chips.height + 6)
+ Text {
+ id: lbl
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 160
+ elide: Text.ElideRight
+ text: s.label
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Flow {
+ id: chips
+ anchors { left: lbl.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 6
+ Repeater {
+ model: [{ value: "", label: s.fallbackLabel }].concat(s.options)
+ Rectangle {
+ id: chip
+ required property var modelData
+ readonly property bool on: s.value === modelData.value
+ implicitWidth: chipText.implicitWidth + 20
+ implicitHeight: 26
+ radius: 13
+ color: on ? Qt.alpha(Theme.accent, 0.85)
+ : chipArea.containsMouse ? Qt.alpha(Theme.accent, 0.22) : Qt.alpha(Theme.surface, 0.8)
+ Text {
+ id: chipText
+ anchors.centerIn: parent
+ text: chip.modelData.label
+ color: chip.on ? Theme.base : Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ MouseArea {
+ id: chipArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Qtm.set(s.section, s.key, chip.modelData.value === "" ? null : chip.modelData.value)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/desktop/modules/qtmaildir/ComposePage.qml b/desktop/modules/qtmaildir/ComposePage.qml
new file mode 100644
index 0000000..c40706f
--- /dev/null
+++ b/desktop/modules/qtmaildir/ComposePage.qml
@@ -0,0 +1,33 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// [compose]. Placeholders are qtmaildir's defaults.
+Column {
+ spacing: 6
+ Note {}
+
+ BoolSetting { section: "compose"; key: "send_html"; label: "Send HTML"; fallback: true
+ hint: "an HTML part beside the plain text, for New and Forward" }
+ ChoiceSetting { section: "compose"; key: "quote_position"; label: "Quote"; fallbackLabel: "Below the reply"
+ options: [{ value: "above", label: "Above the reply" }] }
+ ChoiceSetting { section: "compose"; key: "default_account"; label: "Default account"; fallbackLabel: "None"
+ options: Qtm.accounts.filter(a => Qtm.get("account." + a, "send_command"))
+ .map(a => ({ value: a, label: Qtm.get("account." + a, "label") || a })) }
+ Setting { section: "compose"; key: "send_delay_ms"; label: "Undo send window"; placeholder: "5000"
+ kind: "int"; min: 0; max: 120000; hint: "Milliseconds before the send command runs. 0 sends at once." }
+ Setting { section: "compose"; key: "autosave_interval_ms"; label: "Autosave every"; placeholder: "30000"
+ kind: "int"; min: 1000; max: 3600000; hint: "Milliseconds, at least 1000." }
+ Setting { section: "compose"; key: "attachment_warn_bytes"; label: "Warn attaching over"; placeholder: "26214400"
+ kind: "int"; min: 0; max: 1e12; hint: "Bytes. 26214400 is 25 MiB, most providers' limit." }
+}
diff --git a/desktop/modules/qtmaildir/GeneralPage.qml b/desktop/modules/qtmaildir/GeneralPage.qml
new file mode 100644
index 0000000..2eca3e7
--- /dev/null
+++ b/desktop/modules/qtmaildir/GeneralPage.qml
@@ -0,0 +1,68 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// [general], [sync] and [completion]. Placeholders are qtmaildir's defaults,
+// from its README.
+Column {
+ spacing: 6
+ Note {}
+
+ Heading { text: "Startup" }
+ Setting { section: "general"; key: "startup_query"; label: "Startup query"; placeholder: "Unread"
+ hint: "A saved query or a built-in filter by name: Unread, Inbox, Important, Sent, Drafts, Trash, Spam." }
+ ChoiceSetting { section: "general"; key: "startup_account"; label: "Startup account"; fallbackLabel: "All accounts"
+ options: Qtm.accounts.map(a => ({ value: a, label: Qtm.get("account." + a, "label") || a })) }
+ Setting { section: "general"; key: "language"; label: "Language"; placeholder: "system"
+ hint: "A locale name, it or it_IT; system follows $LANG." }
+
+ Heading { text: "Appearance" }
+ Setting { section: "general"; key: "toolbar_icon_size"; label: "Toolbar icon size"; placeholder: "24"
+ kind: "int"; min: 16; max: 64; hint: "Pixels, 16 to 64." }
+ Setting { section: "general"; key: "message_zoom"; label: "Message zoom"; placeholder: "1.0"
+ kind: "float"; min: 0.5; max: 3; hint: "The starting zoom, 0.5 to 3. Ctrl+wheel is remembered on its own." }
+ Setting { section: "general"; key: "date_format"; label: "Date format"; placeholder: "the system locale's short format"
+ hint: "A QDateTime pattern: yyyy-MM-dd hh:mm." }
+ Setting { section: "general"; key: "forward_prefixes"; label: "Forward prefixes"
+ hint: "Extra subject prefixes, comma-separated, added to Fwd, Fw, WG, ENC, RV, TR." }
+
+ Heading { text: "Behaviour" }
+ Setting { section: "general"; key: "mark_read_delay_ms"; label: "Mark read after"; placeholder: "2000"
+ kind: "int"; min: -1; max: 600000; hint: "Milliseconds an opened thread stays unread. 0 at once, -1 never." }
+ BoolSetting { section: "general"; key: "completion_on_focus"; label: "Complete on focus"; fallback: false
+ hint: "open the completion popup when the empty query bar takes focus" }
+
+ Heading { text: "Sync" }
+ Setting { section: "sync"; key: "command"; label: "Sync command"
+ placeholder: "unset: the Sync button is disabled" }
+ Setting { section: "sync"; key: "log"; label: "Sync log"; placeholder: "the path mailsync.sh writes" }
+ Setting { section: "general"; key: "auto_sync_delay_ms"; label: "Sync after tagging"; placeholder: "2000"
+ kind: "int"; min: -1; max: 600000; hint: "Milliseconds after the last tag change. 0 at once, -1 never." }
+ ChoiceSetting { section: "general"; key: "sync_on_exit"; label: "On quit with changes"; fallbackLabel: "Ask"
+ options: [{ value: "always", label: "Always sync" }, { value: "never", label: "Never" }] }
+
+ Heading { text: "Contacts and calendar" }
+ Setting { section: "general"; key: "contacts_dir"; label: "Contacts"; placeholder: "off"
+ hint: "A vCard directory, e.g. ~/.local/share/vdirsyncer/contacts/. Not ~/.local/share/contacts/, Akonadi's." }
+ Setting { section: "general"; key: "calendars_dir"; label: "Calendars"; placeholder: "off"
+ hint: "The vdir root, e.g. ~/.local/share/calendars/." }
+ Setting { section: "general"; key: "default_calendar"; label: "Default calendar"; placeholder: "the first collection"
+ hint: "The collection's directory name, where new events go." }
+ Setting { section: "general"; key: "calendar_sync_command"; label: "Calendar sync"
+ placeholder: "flock -w 60 /tmp/vdirsyncer.lock vdirsyncer sync calendars" }
+
+ Heading { text: "Advanced" }
+ Setting { section: "general"; key: "notmuch_config"; label: "notmuch config"; placeholder: "notmuch's own lookup" }
+ Setting { section: "completion"; key: "extra_mimetypes"; label: "Extra mimetypes"
+ hint: "Added after mimetype: in the query bar. type|description, comma-separated." }
+}
diff --git a/desktop/modules/qtmaildir/Heading.qml b/desktop/modules/qtmaildir/Heading.qml
new file mode 100644
index 0000000..b1076b3
--- /dev/null
+++ b/desktop/modules/qtmaildir/Heading.qml
@@ -0,0 +1,20 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+Text {
+ topPadding: 14
+ bottomPadding: 2
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+}
diff --git a/desktop/modules/qtmaildir/Ini.js b/desktop/modules/qtmaildir/Ini.js
new file mode 100644
index 0000000..0b23e51
--- /dev/null
+++ b/desktop/modules/qtmaildir/Ini.js
@@ -0,0 +1,138 @@
+// 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 library
+
+// Pure helpers for qtmaildir.conf, tested by test_ini.js under node.
+//
+// The file is walked a line at a time, never cut at the next "[": values
+// hold brackets ([Gmail]/Bozze), and a section body taken as "everything up
+// to the next [" once lost three accounts their labels (AGENTS.md). A header
+// is a line that is only "[name]". Comments and order are kept: a key unset
+// is commented out in place, a commented key that is set again is activated,
+// and anything this file does not touch passes through.
+//
+// Values follow QSettings' INI reading: an unquoted ; starts a comment, and
+// a "quoted" value may hold one.
+
+const HEADER = /^\s*\[([^\]]+)\]\s*(;.*)?$/;
+const KEY = /^(\s*)(;\s*)?([^=;\s][^=]*?)\s*=\s*(.*)$/;
+
+function value(rest) {
+ if (rest.startsWith('"')) {
+ let out = "";
+ for (let i = 1; i < rest.length; i++) {
+ const c = rest[i];
+ if (c === '"') return { v: out, end: i + 1 };
+ if (c === "\\" && i + 1 < rest.length) out += rest[++i];
+ else out += c;
+ }
+ return { v: out, end: rest.length };
+ }
+ const semi = rest.search(/\s*;/);
+ const raw = semi < 0 ? rest : rest.slice(0, semi);
+ return { v: raw.trim(), end: raw.replace(/\s+$/, "").length };
+}
+
+function quote(v) {
+ return /[;"]|^\s|\s$/.test(v) ? '"' + v.replace(/[\\"]/g, c => "\\" + c) + '"' : v;
+}
+
+// [{name, h, end}]: the header's line and the line after the section.
+function ranges(lines) {
+ const out = [];
+ lines.forEach((l, i) => {
+ const m = HEADER.exec(l);
+ if (!m) return;
+ if (out.length) out[out.length - 1].end = i;
+ out.push({ name: m[1].trim(), h: i, end: lines.length });
+ });
+ return out;
+}
+
+function sections(text) { return ranges(text.split("\n")).map(r => r.name); }
+
+// [{key, value}] for the active keys of a section, in file order.
+function entries(text, section) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ if (!r) return [];
+ const out = [];
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (m && !m[2]) out.push({ key: m[3].trim(), value: value(m[4]).v });
+ }
+ return out;
+}
+
+function get(text, section, key) {
+ const e = entries(text, section).find(x => x.key === key);
+ return e ? e.value : undefined;
+}
+
+// value null unsets: the key is commented out where it stands.
+function set(text, section, key, val) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ if (!r) {
+ if (val === null) return text;
+ return text.replace(/\n*$/, "") + "\n\n[" + section + "]\n" + key + " = " + quote(val) + "\n";
+ }
+ let commented = -1;
+ let last = r.h;
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (lines[i].trim() !== "") last = i;
+ if (!m || m[3].trim() !== key) continue;
+ const tail = m[4].slice(value(m[4]).end);
+ if (!m[2]) {
+ lines[i] = val === null ? m[1] + "; " + key + " = " + m[4]
+ : m[1] + key + " = " + quote(val) + tail;
+ return lines.join("\n");
+ }
+ if (commented < 0) commented = i;
+ }
+ if (val === null) return text;
+ if (commented >= 0) {
+ const m = KEY.exec(lines[commented]);
+ lines[commented] = m[1] + key + " = " + quote(val) + m[4].slice(value(m[4]).end);
+ } else lines.splice(last + 1, 0, key + " = " + quote(val));
+ return lines.join("\n");
+}
+
+// Deletes an active key's line outright, for the free-form sections
+// ([tagcolors], [keys]) where a commented-out row would only pile up.
+function remove(text, section, key) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ if (!r) return text;
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (m && !m[2] && m[3].trim() === key) { lines.splice(i, 1); return lines.join("\n"); }
+ }
+ return text;
+}
+
+// pairs: [[key, value], ...]
+function addSection(text, section, pairs) {
+ return text.replace(/\n*$/, "") + "\n\n[" + section + "]\n"
+ + pairs.map(p => p[0] + " = " + quote(p[1])).join("\n") + "\n";
+}
+
+// The header through the line before the next header, which takes the blank
+// line that separated it from the next section with it.
+function removeSection(text, section) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ if (!r) return text;
+ lines.splice(r.h, r.end - r.h);
+ return lines.join("\n");
+}
diff --git a/desktop/modules/qtmaildir/MailAccountsModule.qml b/desktop/modules/qtmaildir/MailAccountsModule.qml
new file mode 100644
index 0000000..f634c8d
--- /dev/null
+++ b/desktop/modules/qtmaildir/MailAccountsModule.qml
@@ -0,0 +1,35 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// qtmaildir's [account.<key>] sections.
+Module {
+ name: "mail-accounts"
+ label: "Accounts"
+ // nf-fa-users, present in Inconsolata Nerd Font.
+ icon: "\uf0c0"
+ settings: true
+ group: "Mail"
+ keywords: ["qtmaildir", "account", "address", "maildir", "trash", "spam", "drafts", "sent",
+ "inbox", "msmtp", "send command", "label", "colour", "color", "channel", "mbsync"]
+ searchItems: Qtm.accounts.map(a => ({ text: a + " " + (Qtm.get("account." + a, "address") ?? ""),
+ key: "account:" + a }))
+ function refresh() { Qtm.refresh(); }
+
+ page: Component {
+ Page {
+ title: "Accounts"
+ AccountsPage { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/qtmaildir/MailComposeModule.qml b/desktop/modules/qtmaildir/MailComposeModule.qml
new file mode 100644
index 0000000..e34fc07
--- /dev/null
+++ b/desktop/modules/qtmaildir/MailComposeModule.qml
@@ -0,0 +1,36 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// qtmaildir's [compose].
+Module {
+ name: "mail-compose"
+ label: "Compose"
+ // nf-fa-pencil, present in Inconsolata Nerd Font.
+ icon: "\uf040"
+ settings: true
+ group: "Mail"
+ keywords: ["qtmaildir", "html", "quote", "reply", "send", "undo", "autosave", "attachment", "draft"]
+ searchItems: [["send_html", "Send HTML"], ["quote_position", "Quote"], ["default_account", "Default account"],
+ ["send_delay_ms", "Undo send window"], ["autosave_interval_ms", "Autosave every"],
+ ["attachment_warn_bytes", "Warn attaching over"]]
+ .map(f => ({ text: f[1], key: "compose/" + f[0] }))
+ function refresh() { Qtm.refresh(); }
+
+ page: Component {
+ Page {
+ title: "Compose"
+ ComposePage { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/qtmaildir/MailGeneralModule.qml b/desktop/modules/qtmaildir/MailGeneralModule.qml
new file mode 100644
index 0000000..b531579
--- /dev/null
+++ b/desktop/modules/qtmaildir/MailGeneralModule.qml
@@ -0,0 +1,46 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// qtmaildir's [general], [sync] and [completion].
+Module {
+ // Not "mail": that is the drawer's unread-mail page.
+ name: "mail-general"
+ label: "Mail"
+ // nf-fa-envelope, present in Inconsolata Nerd Font.
+ icon: "\uf0e0"
+ settings: true
+ group: "Mail"
+ keywords: ["qtmaildir", "startup", "language", "toolbar", "zoom", "date", "sync", "contacts",
+ "calendar", "notmuch", "mimetype", "forward"]
+ readonly property var fields: [
+ ["general", "startup_query", "Startup query"], ["general", "startup_account", "Startup account"],
+ ["general", "language", "Language"], ["general", "toolbar_icon_size", "Toolbar icon size"],
+ ["general", "message_zoom", "Message zoom"], ["general", "date_format", "Date format"],
+ ["general", "forward_prefixes", "Forward prefixes"], ["general", "mark_read_delay_ms", "Mark read after"],
+ ["general", "completion_on_focus", "Complete on focus"], ["sync", "command", "Sync command"],
+ ["sync", "log", "Sync log"], ["general", "auto_sync_delay_ms", "Sync after tagging"],
+ ["general", "sync_on_exit", "On quit with changes"], ["general", "contacts_dir", "Contacts"],
+ ["general", "calendars_dir", "Calendars"], ["general", "default_calendar", "Default calendar"],
+ ["general", "calendar_sync_command", "Calendar sync"], ["general", "notmuch_config", "notmuch config"],
+ ["completion", "extra_mimetypes", "Extra mimetypes"]]
+ searchItems: fields.map(f => ({ text: f[2], key: f[0] + "/" + f[1] }))
+ function refresh() { Qtm.refresh(); }
+
+ page: Component {
+ Page {
+ title: "Mail"
+ GeneralPage { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/qtmaildir/MailTagsKeysModule.qml b/desktop/modules/qtmaildir/MailTagsKeysModule.qml
new file mode 100644
index 0000000..b4c903c
--- /dev/null
+++ b/desktop/modules/qtmaildir/MailTagsKeysModule.qml
@@ -0,0 +1,34 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// qtmaildir's [tagcolors] and [keys].
+Module {
+ name: "mail-tags"
+ label: "Tags & shortcuts"
+ // nf-fa-tags, present in Inconsolata Nerd Font.
+ icon: "\uf02c"
+ settings: true
+ group: "Mail"
+ keywords: ["qtmaildir", "tag", "colour", "color", "shortcut", "key", "binding", "action"]
+ searchItems: Qtm.entries("tagcolors").map(e => ({ text: "tag: " + e.key, key: "tag:" + e.key }))
+ .concat(Qtm.entries("keys").map(e => ({ text: e.key + ": " + e.value, key: "key:" + e.key })))
+ function refresh() { Qtm.refresh(); }
+
+ page: Component {
+ Page {
+ title: "Tags & shortcuts"
+ TagsKeysPage { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/qtmaildir/Note.qml b/desktop/modules/qtmaildir/Note.qml
new file mode 100644
index 0000000..56b8208
--- /dev/null
+++ b/desktop/modules/qtmaildir/Note.qml
@@ -0,0 +1,35 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// The top of every Mail page: when changes apply, and any write error.
+Column {
+ width: parent ? parent.width : 0
+ spacing: 4
+ Text {
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: Qtm.running ? "qtmaildir is running: it reads qtmaildir.conf at startup, so restart it to apply changes."
+ : "qtmaildir reads qtmaildir.conf at startup; changes apply on its next launch."
+ color: Qtm.running ? Theme.yellow : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Text {
+ width: parent.width
+ visible: Qtm.error !== ""
+ wrapMode: Text.Wrap
+ text: Qtm.error
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+}
diff --git a/desktop/modules/qtmaildir/Qtm.qml b/desktop/modules/qtmaildir/Qtm.qml
new file mode 100644
index 0000000..744bcb4
--- /dev/null
+++ b/desktop/modules/qtmaildir/Qtm.qml
@@ -0,0 +1,77 @@
+// 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
+import "Ini.js" as I
+
+// qtmaildir.conf, shared by the four Mail settings pages. qtmaildir reads
+// it at startup and never writes it, so editing it under a running
+// qtmaildir is safe and applies on its next launch.
+Singleton {
+ id: root
+
+ readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config")
+ + "/qtmaildir/qtmaildir.conf"
+ property string text: ""
+ property string error: ""
+ // Whether qtmaildir runs now, so a page can say a change waits for a
+ // restart. Rechecked on every refresh().
+ property bool running: false
+
+ // Keys of the [account.<key>] sections, in file order.
+ readonly property var accounts: I.sections(root.text).filter(s => s.startsWith("account.")).map(s => s.slice(8))
+
+ function get(section, key) { return I.get(root.text, section, key); }
+ function entries(section) { return I.entries(root.text, section); }
+ // val null unsets: the line is commented out where it stands.
+ function set(section, key, val) { root.write(I.set(root.text, section, key, val)); }
+ function remove(section, key) { root.write(I.remove(root.text, section, key)); }
+ function addAccount(key, pairs) { root.write(I.addSection(root.text, "account." + key, pairs)); }
+ function removeAccount(key) { root.write(I.removeSection(root.text, "account." + key)); }
+
+ // Never empty, since a header always remains, so FileView.setText is safe
+ // here (AGENTS.md: it writes nothing for empty text on an unloaded path).
+ function write(t) {
+ if (t === root.text) return;
+ root.error = "";
+ root.text = t;
+ fv.setText(t);
+ }
+
+ function refresh() {
+ fv.reload();
+ pg.running = false;
+ pg.running = true;
+ }
+
+ FileView {
+ id: fv
+ path: root.path
+ atomicWrites: true
+ watchChanges: true
+ printErrors: false
+ // Its own writes come back here too; reparsing the same text is a no-op.
+ onFileChanged: reload()
+ onLoaded: root.text = text()
+ onLoadFailed: root.text = ""
+ onSaveFailed: err => root.error = "Cannot write " + root.path + ": " + FileViewError.toString(err)
+ }
+
+ Process {
+ id: pg
+ command: ["pgrep", "-x", "qtmaildir"]
+ onExited: code => root.running = code === 0
+ }
+}
diff --git a/desktop/modules/qtmaildir/README.md b/desktop/modules/qtmaildir/README.md
new file mode 100644
index 0000000..4bbcbfd
--- /dev/null
+++ b/desktop/modules/qtmaildir/README.md
@@ -0,0 +1,38 @@
+# qtmaildir
+
+Four settings pages, under Mail, for qtmaildir's
+`~/.config/qtmaildir/qtmaildir.conf`:
+
+- **Mail**: `[general]`, `[sync]` and `[completion]`: startup query and
+ account, language, toolbar size, message zoom, date format, forward
+ prefixes, mark-read and auto-sync delays, sync on quit, the sync command
+ and log, contacts, calendars and their sync command, the notmuch config.
+- **Compose**: `[compose]`.
+- **Accounts**: one card per `[account.<key>]` with every key qtmaildir
+ reads; Add appends a section with `trash = Trash` and `spam = Spam`,
+ Remove asks once and deletes the section. The key is the account's
+ identity and is not renamed here.
+- **Tags & shortcuts**: `[tagcolors]` and `[keys]`, a row each, removable,
+ with an add row.
+
+qtmaildir reads the file at startup and never writes it, so editing it
+under a running qtmaildir is safe; the pages say when it is running and a
+change waits for its restart. A field saves on Enter, and emptied it unsets
+the key, so qtmaildir's default applies; the placeholders name those
+defaults, from qtmaildir's README. Numbers are range-checked before writing.
+
+`Ini.js` edits the file a line at a time so its comments and order survive:
+a key unset is commented out with `;`, a commented key set again is
+activated, and a tag or shortcut row removed is deleted outright. Section
+headers are only lines that are nothing but `[name]`, never "everything up
+to the next `[`": values hold brackets (`[Gmail]/Bozze`), which is the trap
+AGENTS.md records. Values follow QSettings' reading: an unquoted `;` starts a
+comment, and a value holding `;` or `"` is written quoted.
+
+The shortcut actions offered are copied from qtmaildir's
+`KeyMap::knownActions()`, a `ponytail:` ceiling in `TagsKeysPage.qml`: a
+new action there needs adding here, and meanwhile can be typed, since the
+field takes free text.
+
+`Qtm.qml` is the singleton the four pages share. Check:
+`node desktop/modules/qtmaildir/test_ini.js`.
diff --git a/desktop/modules/qtmaildir/Setting.qml b/desktop/modules/qtmaildir/Setting.qml
new file mode 100644
index 0000000..59e9c1b
--- /dev/null
+++ b/desktop/modules/qtmaildir/Setting.qml
@@ -0,0 +1,104 @@
+// 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.
+
+import QtQuick
+import "../.."
+
+// One labelled key of qtmaildir.conf: a text field that saves on Enter.
+// Emptied, the key is unset and qtmaildir uses its default, which the
+// placeholder names. kind "int" or "float" checks the number and its range
+// before writing; a refused value shows why under the row.
+Column {
+ id: s
+ property string section
+ property string key
+ property string label
+ property string placeholder: ""
+ property string hint: ""
+ property string kind: "text"
+ property real min: -Infinity
+ property real max: Infinity
+ // What a search hit scrolls to.
+ objectName: section + "/" + key
+ width: parent ? parent.width : 0
+ spacing: 2
+
+ readonly property string value: Qtm.get(s.section, s.key) ?? ""
+ property string problem: ""
+
+ function commit(t) {
+ t = t.trim();
+ if (t === "") { s.problem = ""; Qtm.set(s.section, s.key, null); return; }
+ if (s.kind !== "text") {
+ const n = Number(t);
+ if (isNaN(n) || (s.kind === "int" && !Number.isInteger(n)) || n < s.min || n > s.max) {
+ s.problem = s.kind === "int" ? `a whole number from ${s.min} to ${s.max}`
+ : `a number from ${s.min} to ${s.max}`;
+ return;
+ }
+ }
+ s.problem = "";
+ Qtm.set(s.section, s.key, t);
+ }
+
+ Item {
+ width: s.width
+ height: 34
+ Text {
+ id: lbl
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 160
+ elide: Text.ElideRight
+ text: s.label
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Rectangle {
+ anchors { left: lbl.right; leftMargin: 8; right: parent.right; top: parent.top; bottom: parent.bottom }
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ id: ti
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ text: s.value
+ onAccepted: if (text !== s.value) s.commit(text)
+ Keys.onEscapePressed: event => {
+ if (text !== s.value) text = s.value;
+ else event.accepted = false;
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width
+ elide: Text.ElideRight
+ visible: parent.text === ""
+ text: s.placeholder
+ color: Theme.overlay
+ font: parent.font
+ }
+ }
+ }
+ }
+
+ Text {
+ x: 168
+ width: s.width - x
+ visible: s.problem !== "" || s.hint !== ""
+ wrapMode: Text.Wrap
+ text: s.problem !== "" ? "Must be " + s.problem + "." : s.hint
+ color: s.problem !== "" ? Theme.red : Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ }
+}
diff --git a/desktop/modules/qtmaildir/TagsKeysPage.qml b/desktop/modules/qtmaildir/TagsKeysPage.qml
new file mode 100644
index 0000000..fcbd3a0
--- /dev/null
+++ b/desktop/modules/qtmaildir/TagsKeysPage.qml
@@ -0,0 +1,213 @@
+// 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.
+
+import QtQuick
+import QtQuick.Controls as C
+import "../.."
+
+// [tagcolors] and [keys]: free-form rows, each removable, with an add row.
+// A row's key is its identity, so changing a tag or a shortcut is a remove
+// and an add.
+Column {
+ id: page
+ spacing: 6
+ Note {}
+
+ // ponytail: copied from qtmaildir's KeyMap::knownActions() (src/keymap.cpp);
+ // a new action there needs adding here. The action field also takes free
+ // text, so a missing name is a typo away, not blocked.
+ readonly property var actions: ["next_thread", "prev_thread", "open_thread", "archive", "delete",
+ "restore", "cleanup_stranded", "cleanup_stranded_spam", "empty_trash", "purge", "empty_spam",
+ "spam", "not_spam", "toggle_unread", "mark_all_read", "edit_tags", "tag_rules", "calendar",
+ "flag", "compose", "reply", "reply_all", "reply_no_quote", "forward", "edit_draft",
+ "save_message", "focus_query", "complete_query", "save_query", "select_all", "clear_pane",
+ "clear_selection", "toggle_html", "load_remote", "message_details", "zoom_in", "zoom_out",
+ "zoom_reset", "undo", "sync", "quit"]
+
+ property string confirming: ""
+
+ // The stock ComboBox paints from the palette, light by default; these
+ // are the desktop's colours, so it reads as part of the page.
+ component Combo: C.ComboBox {
+ editable: true
+ model: page.actions
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ palette {
+ base: Theme.surface; window: Theme.base; button: Theme.surface
+ text: Theme.text; buttonText: Theme.text; windowText: Theme.text
+ highlight: Theme.accent; highlightedText: Theme.base
+ mid: Qt.alpha(Theme.text, 0.2); dark: Qt.alpha(Theme.text, 0.2); light: Theme.surface
+ }
+ }
+
+ component Box: Rectangle {
+ property alias text: ti.text
+ property string placeholder: ""
+ signal accepted
+ height: 34
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ id: ti
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ onAccepted: parent.accepted()
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: parent.parent.placeholder
+ color: Theme.overlay
+ font: parent.font
+ }
+ }
+ }
+
+ // --- tag colours ---
+
+ Heading { text: "Tag colours" }
+ Text {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: "By exact tag first, then by top-level prefix: shopping colours shopping/amazon unless it has its own."
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ Repeater {
+ model: Qtm.entries("tagcolors")
+ delegate: Item {
+ id: tagRow
+ required property var modelData
+ objectName: "tag:" + modelData.key
+ width: page.width
+ height: 34
+ Rectangle {
+ id: tagSwatch
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 16; height: 16; radius: 4
+ color: tagRow.modelData.value
+ }
+ Text {
+ id: tagName
+ anchors { left: tagSwatch.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ width: 200
+ elide: Text.ElideRight
+ text: tagRow.modelData.key
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ Box {
+ anchors { left: tagName.right; leftMargin: 8; right: tagDel.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ text: tagRow.modelData.value
+ onAccepted: if (/^#[0-9a-fA-F]{6}$/.test(text)) Qtm.set("tagcolors", tagRow.modelData.key, text)
+ }
+ Button {
+ id: tagDel
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ danger: true
+ text: page.confirming === "tag:" + tagRow.modelData.key ? "Confirm" : "Remove"
+ onClicked: {
+ if (page.confirming === "tag:" + tagRow.modelData.key) { page.confirming = ""; Qtm.remove("tagcolors", tagRow.modelData.key); }
+ else page.confirming = "tag:" + tagRow.modelData.key;
+ }
+ }
+ }
+ }
+
+ Item {
+ width: page.width
+ height: 34
+ readonly property bool ok: newTag.text.trim() !== "" && /^#[0-9a-fA-F]{6}$/.test(newColour.text)
+ && Qtm.get("tagcolors", newTag.text.trim()) === undefined
+ function add() {
+ if (!ok) return;
+ Qtm.set("tagcolors", newTag.text.trim(), newColour.text);
+ newTag.text = ""; newColour.text = "";
+ }
+ Box { id: newTag; anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 226; placeholder: "tag" }
+ Box { id: newColour; anchors { left: newTag.right; leftMargin: 8; right: tagAdd.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ placeholder: "#rrggbb"; onAccepted: parent.add() }
+ Button { id: tagAdd; anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Add"; enabled: parent.ok; onClicked: parent.add() }
+ }
+
+ // --- keys ---
+
+ Heading { text: "Shortcuts" }
+ Text {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: "Overrides of qtmaildir's default bindings: a key sequence such as Ctrl+E, and the action it runs."
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ Repeater {
+ model: Qtm.entries("keys")
+ delegate: Item {
+ id: keyRow
+ required property var modelData
+ objectName: "key:" + modelData.key
+ width: page.width
+ height: 34
+ Text {
+ id: keySeq
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 226
+ elide: Text.ElideRight
+ text: keyRow.modelData.key
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ Combo {
+ anchors { left: keySeq.right; leftMargin: 8; right: keyDel.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ Component.onCompleted: editText = keyRow.modelData.value
+ onAccepted: if (editText.trim() !== "") Qtm.set("keys", keyRow.modelData.key, editText.trim())
+ onActivated: i => Qtm.set("keys", keyRow.modelData.key, page.actions[i])
+ }
+ Button {
+ id: keyDel
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ danger: true
+ text: page.confirming === "key:" + keyRow.modelData.key ? "Confirm" : "Remove"
+ onClicked: {
+ if (page.confirming === "key:" + keyRow.modelData.key) { page.confirming = ""; Qtm.remove("keys", keyRow.modelData.key); }
+ else page.confirming = "key:" + keyRow.modelData.key;
+ }
+ }
+ }
+ }
+
+ Item {
+ width: page.width
+ height: 34
+ readonly property bool ok: newSeq.text.trim() !== "" && newAction.editText.trim() !== ""
+ && Qtm.get("keys", newSeq.text.trim()) === undefined
+ function add() {
+ if (!ok) return;
+ Qtm.set("keys", newSeq.text.trim(), newAction.editText.trim());
+ newSeq.text = "";
+ }
+ Box { id: newSeq; anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 226; placeholder: "Ctrl+E" }
+ Combo {
+ id: newAction
+ anchors { left: newSeq.right; leftMargin: 8; right: keyAdd.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ }
+ Button { id: keyAdd; anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Add"; enabled: parent.ok; onClicked: parent.add() }
+ }
+}
diff --git a/desktop/modules/qtmaildir/test_ini.js b/desktop/modules/qtmaildir/test_ini.js
new file mode 100644
index 0000000..2e7fb03
--- /dev/null
+++ b/desktop/modules/qtmaildir/test_ini.js
@@ -0,0 +1,93 @@
+// 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.
+
+// Run: node desktop/modules/qtmaildir/test_ini.js
+// Ini.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Ini.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const I = new Function(src + "\nreturn { sections, entries, get, set, remove, addSection, removeSection };")();
+
+const file = [
+ "[general]",
+ "; startup_query = Unread",
+ "toolbar_icon_size = 32",
+ "date_format = dd/MM/yyyy hh:mm",
+ "",
+ "[account.gmail]",
+ "maildir = gmail ; relative to notmuch's mail root",
+ "drafts = [Gmail]/Bozze",
+ 'send_command = "/usr/bin/msmtp -a x ; y"',
+ "label = GM",
+ "",
+ "[tagcolors]",
+ "shopping/amazon = #ff9900",
+ "",
+].join("\n");
+
+// A value holding brackets is not a section header (AGENTS.md: three of
+// five accounts once lost their label to exactly that).
+assert.deepStrictEqual(I.sections(file), ["general", "account.gmail", "tagcolors"]);
+assert.strictEqual(I.get(file, "account.gmail", "drafts"), "[Gmail]/Bozze");
+assert.strictEqual(I.get(file, "account.gmail", "label"), "GM");
+// An inline comment is not part of the value; a quoted ; is.
+assert.strictEqual(I.get(file, "account.gmail", "maildir"), "gmail");
+assert.strictEqual(I.get(file, "account.gmail", "send_command"), "/usr/bin/msmtp -a x ; y");
+// A commented key is unset.
+assert.strictEqual(I.get(file, "general", "startup_query"), undefined);
+assert.deepStrictEqual(I.entries(file, "tagcolors"), [{ key: "shopping/amazon", value: "#ff9900" }]);
+
+// Replacing keeps the inline comment and every other line.
+let t = I.set(file, "account.gmail", "maildir", "gm2");
+assert.strictEqual(t.split("\n")[6], "maildir = gm2 ; relative to notmuch's mail root");
+assert.strictEqual(t.split("\n").length, file.split("\n").length);
+// Setting a commented key activates its line.
+t = I.set(file, "general", "startup_query", "Inbox");
+assert.strictEqual(t.split("\n")[1], "startup_query = Inbox");
+// Unsetting comments a key out in place.
+t = I.set(file, "general", "toolbar_icon_size", null);
+assert.strictEqual(t.split("\n")[2], "; toolbar_icon_size = 32");
+assert.strictEqual(I.get(t, "general", "toolbar_icon_size"), undefined);
+// A new key lands at the end of its section, before the blank line.
+t = I.set(file, "general", "language", "it");
+assert.strictEqual(t.split("\n")[4], "language = it");
+assert.strictEqual(t.split("\n")[5], "");
+assert.strictEqual(I.get(t, "general", "language"), "it");
+// A value with a ; or a quote is quoted, and reads back the same.
+t = I.set(file, "account.gmail", "send_command", 'a "b" ; c');
+assert.strictEqual(I.get(t, "account.gmail", "send_command"), 'a "b" ; c');
+// A missing section is appended.
+t = I.set(file, "compose", "send_html", "false");
+assert.ok(t.endsWith("\n[compose]\nsend_html = false\n"), JSON.stringify(t));
+assert.strictEqual(I.get(t, "compose", "send_html"), "false");
+// The same key in another section is untouched.
+t = I.set("[a]\nx = 1\n\n[b]\nx = 2\n", "b", "x", "3");
+assert.strictEqual(t, "[a]\nx = 1\n\n[b]\nx = 3\n");
+
+// remove deletes the line, where set(null) would comment it out.
+t = I.remove(file, "tagcolors", "shopping/amazon");
+assert.deepStrictEqual(I.entries(t, "tagcolors"), []);
+assert.ok(!t.includes("amazon"));
+assert.strictEqual(I.remove(file, "tagcolors", "nope"), file);
+
+// Whole sections, for accounts.
+t = I.addSection(file, "account.new", [["maildir", "new"], ["trash", "Trash"]]);
+assert.deepStrictEqual(I.sections(t), ["general", "account.gmail", "tagcolors", "account.new"]);
+assert.strictEqual(I.get(t, "account.new", "trash"), "Trash");
+t = I.removeSection(file, "account.gmail");
+assert.deepStrictEqual(I.sections(t), ["general", "tagcolors"]);
+assert.strictEqual(t, "[general]\n; startup_query = Unread\ntoolbar_icon_size = 32\ndate_format = dd/MM/yyyy hh:mm\n\n[tagcolors]\nshopping/amazon = #ff9900\n");
+
+console.log("test_ini: ok");
diff --git a/desktop/shell.qml b/desktop/shell.qml
index ea2ce67..1cd1f7e 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -21,6 +21,7 @@ import "modules/ai"
import "modules/appearance"
import "modules/assistant"
import "modules/autostart"
+import "modules/qtmaildir"
import "modules/bluetooth"
import "modules/clipboard"
import "modules/git"
@@ -83,6 +84,10 @@ ShellRoot {
AutostartModule {},
AssistantModule { id: asstSettings },
PresetsModule {},
+ MailGeneralModule {},
+ MailComposeModule {},
+ MailAccountsModule {},
+ MailTagsKeysModule {},
GitModule {},
]
}