summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-02 17:21:47 +0200
committerDanilo M. <danix@danix.xyz>2026-08-02 17:21:47 +0200
commitb61ce428d81871fbbc6261f274f20785db9d1c38 (patch)
treeb1ebcf1da244a31ec1c991be42d0062d776ba1b4
parentdb1eb37b26f9ac14be4e1a4e5edbf5b6e77f8db7 (diff)
downloadqtmaildir-b61ce428d81871fbbc6261f274f20785db9d1c38.tar.gz
qtmaildir-b61ce428d81871fbbc6261f274f20785db9d1c38.zip
feat: add Config with account, query, and sync parsing
Accounts use [account.work] rather than [account/work]: QSettings' INI backend treats "/" as its own hierarchical group separator, so a literal slash in a section header parses as a nested group and trips QSettings::FormatError, silently breaking childGroups() enumeration. A dot carries no such meaning and keeps the format flat. Saved-query order is alphabetical (QSettings::childKeys() sorts), not file order; documented in code and tests rather than left to a false assumption.
-rw-r--r--docs/superpowers/plans/2026-08-02-qtmaildir-v1.md12
-rw-r--r--docs/superpowers/specs/2026-08-02-qtmaildir-design.md4
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/config.cpp89
-rw-r--r--src/config.h62
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_config.cpp132
7 files changed, 293 insertions, 8 deletions
diff --git a/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md b/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md
index 2defcc5..b264986 100644
--- a/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md
+++ b/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md
@@ -544,13 +544,13 @@ void TestConfig::parsesAccounts()
{
QTemporaryDir dir;
const QString path = writeIni(dir, QStringLiteral(
- "[account/work]\n"
+ "[account.work]\n"
"name=Test User\n"
"address=user@example.org\n"
"maildir=work-mail\n"
"drafts=Drafts\n"
"\n"
- "[account/personal]\n"
+ "[account.personal]\n"
"name=Test User\n"
"address=me@example.net\n"
"maildir=personal\n"
@@ -610,7 +610,7 @@ void TestConfig::accountWithoutMaildirIsRejected()
{
QTemporaryDir dir;
const QString path = writeIni(dir, QStringLiteral(
- "[account/broken]\n"
+ "[account.broken]\n"
"name=No Maildir\n"
"address=x@example.org\n"
));
@@ -666,7 +666,7 @@ Expected: FAIL, `config.h: No such file or directory`.
/// identity.
struct Account
{
- QString key; ///< INI group suffix, e.g. "work" from [account/work].
+ QString key; ///< INI group suffix, e.g. "work" from [account.work].
QString name;
QString address;
QString maildir; ///< Relative to notmuch's database.path.
@@ -762,11 +762,11 @@ void Config::load(const QString &path)
}
for (const QString &group : settings.childGroups()) {
- if (!group.startsWith(QStringLiteral("account/")))
+ if (!group.startsWith(QStringLiteral("account.")))
continue;
Account account;
- account.key = group.mid(QStringLiteral("account/").size());
+ account.key = group.mid(QStringLiteral("account.").size());
settings.beginGroup(group);
account.name = settings.value(QStringLiteral("name")).toString();
diff --git a/docs/superpowers/specs/2026-08-02-qtmaildir-design.md b/docs/superpowers/specs/2026-08-02-qtmaildir-design.md
index b92c939..a2b4955 100644
--- a/docs/superpowers/specs/2026-08-02-qtmaildir-design.md
+++ b/docs/superpowers/specs/2026-08-02-qtmaildir-design.md
@@ -205,13 +205,13 @@ identity.
[sync]
command = /home/danix/bin/mailsync.sh
-[account/privateemail-danix]
+[account.privateemail-danix]
name = Danilo M.
address = danix@danix.xyz
maildir = privateemail-danix ; relative to notmuch's database.path
drafts = Drafts
-[account/gmail]
+[account.gmail]
name = Danilo M.
address = <the gmail address>
maildir = gmail
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index aeb425f..d1ed29f 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -1,5 +1,6 @@
add_library(qtmaildir_lib STATIC
keymap.cpp
+ config.cpp
)
target_include_directories(qtmaildir_lib
diff --git a/src/config.cpp b/src/config.cpp
new file mode 100644
index 0000000..b12130d
--- /dev/null
+++ b/src/config.cpp
@@ -0,0 +1,89 @@
+#include "config.h"
+
+#include <QFileInfo>
+#include <QSettings>
+#include <QStandardPaths>
+
+QString Account::scopedQuery(const QString &query) const
+{
+ const QString prefix = QStringLiteral("path:\"%1/**\"").arg(maildir);
+ if (query.trimmed().isEmpty())
+ return prefix;
+ return QStringLiteral("%1 and (%2)").arg(prefix, query);
+}
+
+QString Config::defaultPath()
+{
+ const QString base =
+ QStandardPaths::writableLocation(QStandardPaths::ConfigLocation);
+ return base + QStringLiteral("/qtmaildir/qtmaildir.conf");
+}
+
+void Config::load(const QString &path)
+{
+ QSettings settings(path, QSettings::IniFormat);
+
+ m_notmuchConfig =
+ settings.value(QStringLiteral("general/notmuch_config")).toString();
+
+ m_syncCommand = settings.value(QStringLiteral("sync/command")).toString();
+ if (m_syncCommand.isEmpty()) {
+ m_warnings.append(QStringLiteral(
+ "No sync command configured ([sync] command); syncing is disabled."));
+ } else if (!QFileInfo::exists(m_syncCommand.split(QLatin1Char(' ')).first())) {
+ m_warnings.append(
+ QStringLiteral("Sync command '%1' does not exist; syncing is disabled.")
+ .arg(m_syncCommand));
+ m_syncCommand.clear();
+ }
+
+ // Account groups are written as [account.work], [account.personal], etc.
+ // A dot, not a slash, separates the "account" namespace from the key:
+ // QSettings' INI backend treats "/" as its own hierarchical group
+ // separator, so a literal "[account/work]" section header would be
+ // parsed as a *nested* group "work" inside a group "account" (and trips
+ // a QSettings::FormatError besides), not as a single flat group named
+ // "account/work". "." carries no such meaning to QSettings, so
+ // childGroups() here returns "account.work" and "account.personal" as
+ // plain top-level entries and status() stays NoError.
+ for (const QString &group : settings.childGroups()) {
+ if (!group.startsWith(QStringLiteral("account.")))
+ continue;
+
+ Account account;
+ account.key = group.mid(QStringLiteral("account.").size());
+
+ settings.beginGroup(group);
+ account.name = settings.value(QStringLiteral("name")).toString();
+ account.address = settings.value(QStringLiteral("address")).toString();
+ account.maildir = settings.value(QStringLiteral("maildir")).toString();
+ account.drafts = settings.value(QStringLiteral("drafts")).toString();
+ settings.endGroup();
+
+ if (!account.isValid()) {
+ m_warnings.append(
+ QStringLiteral("Account '%1' has no maildir; ignoring it.")
+ .arg(account.key));
+ continue;
+ }
+ m_accounts.append(account);
+ }
+
+ settings.beginGroup(QStringLiteral("queries"));
+ // QSettings::childKeys() returns keys in alphabetical order, not file
+ // order, so the saved-query button order in the UI is alphabetical too.
+ // A hand-rolled parser would be needed to preserve file order; not
+ // needed in v1.
+ for (const QString &name : settings.childKeys())
+ m_savedQueries.append({ name, settings.value(name).toString() });
+ settings.endGroup();
+}
+
+Account Config::account(const QString &key) const
+{
+ for (const Account &a : m_accounts) {
+ if (a.key == key)
+ return a;
+ }
+ return {};
+}
diff --git a/src/config.h b/src/config.h
new file mode 100644
index 0000000..f037db5
--- /dev/null
+++ b/src/config.h
@@ -0,0 +1,62 @@
+#pragma once
+
+#include <QList>
+#include <QString>
+#include <QStringList>
+
+/// One mail account. notmuch has no concept of accounts; it sees a single flat
+/// tree. An account is therefore a path prefix within that tree plus an
+/// identity.
+struct Account
+{
+ QString key; ///< INI group suffix, e.g. "work" from [account/work].
+ QString name;
+ QString address;
+ QString maildir; ///< Relative to notmuch's database.path.
+ QString drafts; ///< Unused in v1; send is v2.
+
+ bool isValid() const { return !key.isEmpty() && !maildir.isEmpty(); }
+
+ /// Restricts a notmuch query to this account's subtree.
+ QString scopedQuery(const QString &query) const;
+};
+
+struct SavedQuery
+{
+ QString name;
+ QString query;
+};
+
+/// Reads ~/.config/qtmaildir/qtmaildir.conf.
+///
+/// The Maildir path is deliberately NOT configurable here: notmuch already
+/// stores it as database.path and libnotmuch reads it. Duplicating it would
+/// allow the GUI to index a different tree than the CLI.
+class Config
+{
+public:
+ /// Path used when load() is called with no argument.
+ static QString defaultPath();
+
+ void load(const QString &path);
+
+ QList<Account> accounts() const { return m_accounts; }
+ Account account(const QString &key) const;
+ QList<SavedQuery> savedQueries() const { return m_savedQueries; }
+
+ /// Empty when unset; the caller disables the Sync button in that case.
+ QString syncCommand() const { return m_syncCommand; }
+
+ /// Optional alternate notmuch config file. Empty means "let notmuch decide".
+ QString notmuchConfig() const { return m_notmuchConfig; }
+
+ /// Non-fatal problems, shown once in a startup banner.
+ QStringList warnings() const { return m_warnings; }
+
+private:
+ QList<Account> m_accounts;
+ QList<SavedQuery> m_savedQueries;
+ QString m_syncCommand;
+ QString m_notmuchConfig;
+ QStringList m_warnings;
+};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index d4f0d3f..bec7621 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -6,3 +6,4 @@ function(add_qtmaildir_test name)
endfunction()
add_qtmaildir_test(keymap)
+add_qtmaildir_test(config)
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
new file mode 100644
index 0000000..0caa93c
--- /dev/null
+++ b/tests/test_config.cpp
@@ -0,0 +1,132 @@
+#include <QtTest>
+#include <QTemporaryDir>
+#include <QSettings>
+#include "config.h"
+
+class TestConfig : public QObject
+{
+ Q_OBJECT
+private slots:
+ void parsesAccounts();
+ void parsesSavedQueries();
+ void missingSyncCommandIsEmpty();
+ void accountWithoutMaildirIsRejected();
+ void scopedQueryWrapsCorrectly();
+};
+
+static QString writeIni(const QTemporaryDir &dir, const QString &body)
+{
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile f(path);
+ f.open(QIODevice::WriteOnly | QIODevice::Text);
+ f.write(body.toUtf8());
+ f.close();
+ return path;
+}
+
+void TestConfig::parsesAccounts()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ "drafts=Drafts\n"
+ "\n"
+ "[account.personal]\n"
+ "name=Test User\n"
+ "address=me@example.net\n"
+ "maildir=personal\n"
+ ));
+
+ Config config;
+ config.load(path);
+
+ QCOMPARE(config.accounts().size(), 2);
+
+ const Account work = config.account(QStringLiteral("work"));
+ QCOMPARE(work.key, QStringLiteral("work"));
+ QCOMPARE(work.name, QStringLiteral("Test User"));
+ QCOMPARE(work.address, QStringLiteral("user@example.org"));
+ QCOMPARE(work.maildir, QStringLiteral("work-mail"));
+ QCOMPARE(work.drafts, QStringLiteral("Drafts"));
+
+ // drafts is optional in v1 (send is v2).
+ const Account personal = config.account(QStringLiteral("personal"));
+ QVERIFY(personal.drafts.isEmpty());
+ QVERIFY(personal.isValid());
+}
+
+void TestConfig::parsesSavedQueries()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[queries]\n"
+ "Inbox=tag:inbox\n"
+ "Unread=tag:unread\n"
+ ));
+
+ Config config;
+ config.load(path);
+
+ const QList<SavedQuery> queries = config.savedQueries();
+ QCOMPARE(queries.size(), 2);
+ // QSettings::childKeys() returns keys alphabetically, not in file order,
+ // so the UI button order is alphabetical. This assertion happens to hold
+ // either way since "Inbox" < "Unread", but the ordering guarantee is
+ // alphabetical, not "follows the file".
+ QCOMPARE(queries.at(0).name, QStringLiteral("Inbox"));
+ QCOMPARE(queries.at(0).query, QStringLiteral("tag:inbox"));
+}
+
+void TestConfig::missingSyncCommandIsEmpty()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral("[general]\n"));
+
+ Config config;
+ config.load(path);
+
+ QVERIFY(config.syncCommand().isEmpty());
+ // The UI uses this to disable the Sync button with a tooltip.
+ QVERIFY(!config.warnings().isEmpty());
+}
+
+void TestConfig::accountWithoutMaildirIsRejected()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[sync]\n"
+ "command=/bin/true\n"
+ "\n"
+ "[account.broken]\n"
+ "name=No Maildir\n"
+ "address=x@example.org\n"
+ ));
+
+ Config config;
+ config.load(path);
+
+ // Rejected, reported, and not offered to the user as a scope.
+ QCOMPARE(config.accounts().size(), 0);
+ QCOMPARE(config.warnings().size(), 1);
+ QVERIFY(config.warnings().first().contains(QStringLiteral("broken")));
+}
+
+void TestConfig::scopedQueryWrapsCorrectly()
+{
+ Account account;
+ account.key = QStringLiteral("work");
+ account.maildir = QStringLiteral("work-mail");
+
+ QCOMPARE(account.scopedQuery(QStringLiteral("tag:inbox")),
+ QStringLiteral("path:\"work-mail/**\" and (tag:inbox)"));
+
+ // An empty query still scopes to the account rather than matching nothing.
+ QCOMPARE(account.scopedQuery(QString()),
+ QStringLiteral("path:\"work-mail/**\""));
+}
+
+QTEST_MAIN(TestConfig)
+#include "test_config.moc"