/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * 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. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #include "config.h" #include "mailsync.h" // For kMinZoom/kMaxZoom. The bounds live with the widget that enforces them, // so this reports the same numbers rather than keeping a second copy. #include "messageview.h" #include #include #include #include #include #include #include #include #include #include #include namespace { /// Bounds for [general] toolbar_icon_size. 16 is the smallest size the icon /// themes actually ship art for, and is what this desktop's style reports; /// above 64 the toolbar is taller than the thread rows it sits over. constexpr int kMinToolbarIconSize = 16; constexpr int kMaxToolbarIconSize = 64; /// queries.json format version. Bump only for a BREAKING change: an optional /// field needs no bump, because an older build preserves what it does not /// understand rather than dropping it. /// /// Unlike rules.json this file has ONE implementation, so a bump here is not a /// two-repo change and no hook stops tagging if it is half-deployed. constexpr int kQueriesFormatVersion = 1; /// Generators a saved query may name in its `generated` field. /// /// A closed set, checked on load so a typo is reported rather than producing a /// button that silently finds nothing. Adding one here needs no format bump: /// an older build keeps the row and reports it, which is why an unknown /// generator is a problem rather than a reason to drop the entry. const QStringList kQueryGenerators = { QStringLiteral("unread"), QStringLiteral("inbox"), QStringLiteral("flagged"), QStringLiteral("sent") }; /// The tag a generator matches, for the three filters that are a plain tag /// query. Empty for "sent", which composes from each account's folder instead /// and is handled separately. QString generatorTag(const QString &generator) { if (generator == QStringLiteral("unread")) return QStringLiteral("unread"); if (generator == QStringLiteral("inbox")) return QStringLiteral("inbox"); if (generator == QStringLiteral("flagged")) return QStringLiteral("flagged"); return QString(); } } // namespace 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); } namespace { /// Composes `path:"//**"`, or empty when the folder is unset. /// /// The QUOTES are load-bearing, not decoration. A real provider nests both its /// sent and its drafts folder under a bracketed parent with a localised name, /// "[Provider]/Posta inviata" and "[Provider]/Bozze", and "[" and "]" are /// Xapian syntax: unquoted, the term is parsed rather than matched and the /// query silently returns nothing while looking correct. /// /// The path is user config and is interpolated into a query, so this is the /// only place that composition happens; a caller building it by hand would be /// a second chance to forget the quotes. QString folderQuery(const QString &maildir, const QString &folder) { if (folder.isEmpty()) return QString(); return QStringLiteral("path:\"%1/%2/**\"").arg(maildir, folder); } /// Joins the non-empty results of `extract` across `accounts` with " or ". /// /// Collect first, join after. Appending "or" per account and trimming the /// result is the version that produced the defect this guards: an account with /// no key contributes an empty term, notmuch accepts the bare "or" without /// complaint, and the query quietly means something else. Measured against a /// real database, `A or or B` returns 190 where the correct pair returns 211. QString joinAccountQueries(const QList &accounts, QString (Account::*extract)() const) { QStringList parts; for (const Account &account : accounts) { const QString query = (account.*extract)(); if (!query.isEmpty()) parts.append(query); } return parts.join(QStringLiteral(" or ")); } } // namespace QString Account::sentQuery() const { return folderQuery(maildir, sent); } QString Account::draftsQuery() const { return folderQuery(maildir, drafts); } QString Config::allSentQuery() const { return joinAccountQueries(m_accounts, &Account::sentQuery); } QString Config::allDraftsQuery() const { return joinAccountQueries(m_accounts, &Account::draftsQuery); } QString Config::defaultPath() { const QString base = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation); return base + QStringLiteral("/qtmaildir/qtmaildir.conf"); } void Config::addProblem(const QString &message) { m_warnings.append(message); m_problems.append(message); } void Config::addNotice(const QString &message) { m_warnings.append(message); } void Config::load(const QString &path) { QSettings settings(path, QSettings::IniFormat); // Keys of [general] are read WITHOUT the "general/" prefix. QSettings' // INI backend treats a section literally named [general] as its own // fallback section and strips the prefix, so "general/notmuch_config" // never matches anything, in any section arrangement (verified on // Qt 6.11). The file still reads as [general] to the user; only the // lookup differs. Same family of trap as the [account.work] dot and the // childKeys() ordering already documented in CLAUDE.md. m_notmuchConfig = settings.value(QStringLiteral("notmuch_config")).toString(); // Absent is fine and silent: the default is 1.0. Present but unparseable // is a problem, since the user asked for something and is not getting it. // The range is enforced by MessageView::clampZoom(), the one place that // knows what the web view can render; out of range is reported below. // Empty is treated as unset rather than as "a query named nothing". const QString startup = settings.value(QStringLiteral("startup_query")).toString().trimmed(); if (!startup.isEmpty()) { m_startupQuery = startup; m_startupQueryWasSet = true; } const QVariant zoom = settings.value(QStringLiteral("message_zoom")); if (zoom.isValid()) { bool ok = false; const double value = zoom.toString().toDouble(&ok); if (ok) { m_messageZoom = value; // Reported, not clamped: MessageView::clampZoom() owns the bounds // and already stops this reaching the web view, so clamping here // too would be a second copy of the range, free to drift from the // first. What was missing is the report. The value parses, so // nothing ever said the 500 in the file is not what is on screen. if (value < MessageView::kMinZoom || value > MessageView::kMaxZoom) { addProblem(QStringLiteral("Message zoom %1 is outside %2 to %3; " "using the nearest allowed value.") .arg(value) .arg(MessageView::kMinZoom) .arg(MessageView::kMaxZoom)); } } else { addProblem(QStringLiteral("Message zoom '%1' is not a number; " "using the default.") .arg(zoom.toString())); } } // A [general] key, so no prefix, per the note at the top of load(). m_completionOnFocus = settings.value(QStringLiteral("completion_on_focus"), false).toBool(); // Clamped, unlike message_zoom above, which documents a 0.5 to 3.0 range in // the README and enforces none of it. Both ends here are unrecoverable from // the UI they break: too small is an invisible icon, too large is a toolbar // taller than the window, and in either case the control the user would // reach for to fix it is the one that just broke. const QVariant iconSize = settings.value(QStringLiteral("toolbar_icon_size")); if (iconSize.isValid()) { bool ok = false; const int value = iconSize.toString().trimmed().toInt(&ok); if (!ok) { addProblem(QStringLiteral("Toolbar icon size '%1' is not a number; " "using %2.") .arg(iconSize.toString()) .arg(m_toolbarIconSize)); } else if (value < kMinToolbarIconSize || value > kMaxToolbarIconSize) { m_toolbarIconSize = qBound(kMinToolbarIconSize, value, kMaxToolbarIconSize); addProblem(QStringLiteral("Toolbar icon size %1 is outside %2 to " "%3; using %4.") .arg(value) .arg(kMinToolbarIconSize) .arg(kMaxToolbarIconSize) .arg(m_toolbarIconSize)); } else { m_toolbarIconSize = value; } } // Absent or empty means the system locale's short format, which is what // every other application on the desktop shows. Only a non-empty pattern is // validated, and a rejected one falls back to that same default. const QString dateFormat = settings.value(QStringLiteral("date_format")).toString().trimmed(); if (!dateFormat.isEmpty()) { // QDateTime::toString() with a pattern carrying no date or time field // returns the pattern verbatim rather than failing, so "banana" would // print "banana" on every card. Formatting two DIFFERENT instants and // comparing is what catches that: a pattern with any real field gives // two different strings, one with none gives the same string twice. const QDateTime a(QDate(2028, 12, 28), QTime(22, 58)); const QDateTime b(QDate(2019, 1, 3), QTime(4, 5)); const QLocale locale = QLocale::system(); if (locale.toString(a, dateFormat) == locale.toString(b, dateFormat)) { addProblem(QStringLiteral("Date format '%1' contains no date or " "time field; using the system format.") .arg(dateFormat)); } else { m_dateFormat = dateFormat; } } // Absent is silent, the default being 2000. Present but unparseable warns, // for the same reason message_zoom does: the user asked for something and // is not getting it. // // Zero and negative are NOT errors and must not be clamped. Zero means mark // read at once, and any negative value means never, which is how the // behaviour is turned off. // Three values, not a bool: "prompt me", "just do it" and "do nothing" are // three distinct behaviours and true/false can only express two of them. const QString syncExit = settings.value(QStringLiteral("sync_on_exit"), QStringLiteral("ask")).toString().trimmed().toLower(); if (syncExit == QStringLiteral("ask")) { m_syncOnExit = SyncOnExit::Ask; } else if (syncExit == QStringLiteral("always")) { m_syncOnExit = SyncOnExit::Always; } else if (syncExit == QStringLiteral("never")) { m_syncOnExit = SyncOnExit::Never; } else { // Naming the accepted values, since a typo here silently changes what // happens to unsynced work at exit. addProblem(QStringLiteral("Unknown sync_on_exit '%1'; expected ask, " "always or never. Using ask.") .arg(syncExit)); } const QVariant markRead = settings.value(QStringLiteral("mark_read_delay_ms")); if (markRead.isValid()) { bool ok = false; const int value = markRead.toString().toInt(&ok); if (ok) { m_markReadDelayMs = value; } else { addProblem(QStringLiteral("Mark-read delay '%1' is not a number; " "using the default.") .arg(markRead.toString())); } } // Item 71. Same shape as mark_read_delay_ms above, including that zero and // negative are not errors: 0 syncs on the next trip through the event loop // and negative disables the automatic sync entirely, which is how a user who // wants only their cron job turns this off. const QVariant autoSync = settings.value(QStringLiteral("auto_sync_delay_ms")); if (autoSync.isValid()) { bool ok = false; const int value = autoSync.toString().toInt(&ok); if (ok) { m_autoSyncDelayMs = value; } else { addProblem(QStringLiteral("Auto-sync delay '%1' is not a number; " "using the default.") .arg(autoSync.toString())); } } // [completion] is an ordinary section, so this one DOES take its prefix. // ',' separates entries and '|' separates a value from its description: // two different characters because QSettings splits comma lists itself, // so a description holding a comma would otherwise become two entries. // Neither character is legal in a mimetype. const QStringList rawMimetypes = settings.value(QStringLiteral("completion/extra_mimetypes")).toStringList(); for (const QString &raw : rawMimetypes) { const QString entry = raw.trimmed(); if (entry.isEmpty()) continue; const int bar = entry.indexOf(QLatin1Char('|')); const QString value = (bar < 0 ? entry : entry.left(bar)).trimmed(); const QString description = (bar < 0 ? QString() : entry.mid(bar + 1)).trimmed(); // Skip only the bad entry: one typo must not cost the user the rest // of the list, and the built-ins are appended to regardless. if (value.isEmpty()) { addProblem(QStringLiteral("[completion] extra_mimetypes: entry '%1' " "has no mimetype; ignoring it.") .arg(entry)); continue; } m_extraMimetypes.append({ value, description }); } m_syncCommand = settings.value(QStringLiteral("sync/command")).toString(); if (m_syncCommand.isEmpty()) { // Not a problem: sync is optional, and nothing the user asked for is // being ignored. A modal here would fire on every launch. addNotice(QStringLiteral( "No sync command configured ([sync] command); syncing is disabled.")); } else if (!QFileInfo::exists(m_syncCommand.split(QLatin1Char(' ')).first())) { addProblem( QStringLiteral("Sync command '%1' does not exist; syncing is disabled.") .arg(m_syncCommand)); m_syncCommand.clear(); } // Not validated for existence, unlike the command above. The log is written // by the script when it runs, so a fresh install has no file yet, and a // startup problem reported for that would be noise. A missing file simply // reads as SyncOutcome::Unknown when the time comes. m_syncLog = settings.value(QStringLiteral("sync/log")).toString().trimmed(); if (m_syncLog.isEmpty()) m_syncLog = MailSync::defaultLogPath(); // 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(); // Optional, and absent for an account that keeps no sent mail locally. // Trimmed because a trailing space would land inside the quoted path // and match nothing, which is invisible in a config file. account.sent = settings.value(QStringLiteral("sent")).toString().trimmed(); // Both optional, and both describe this account's chip in the thread // list. An account tag is a different taxonomy from a functional one, // saying which mailbox a thread arrived in rather than what state it // is in, so these live here rather than in [tagcolors]. account.label = settings.value(QStringLiteral("label")).toString(); // Optional, and absent for most accounts: syncChannel() falls back to // the key. Needed only where the section key and the mbsync channel // name diverge. account.channel = settings.value(QStringLiteral("channel")).toString(); const QString colour = settings.value(QStringLiteral("color")).toString(); if (!colour.isEmpty()) { account.color = QColor(colour); if (!account.color.isValid()) { addProblem( QStringLiteral("Account '%1' has an unparseable color '%2'; " "using a generated one.") .arg(account.key, colour)); } } settings.endGroup(); if (!account.isValid()) { addProblem( QStringLiteral("Account '%1' has no maildir; ignoring it.") .arg(account.key)); continue; } m_accounts.append(account); } loadSavedQueries(path, settings); // Checked here rather than where startup_query is read: the saved queries // are not parsed until now. Only a name the user actually wrote is worth a // problem; the built-in default naming a query they never created is not // something they got wrong. if (m_startupQueryWasSet && !m_savedQueries.isEmpty() && startupSavedQuery().name.compare(m_startupQuery, Qt::CaseInsensitive) != 0) { addProblem(QStringLiteral("Startup query '%1' is not a saved query; " "opening '%2' instead.") .arg(m_startupQuery, startupSavedQuery().name)); } } QString Config::queriesPath(const QString &configPath) { return QFileInfo(configPath).absolutePath() + QStringLiteral("/queries.json"); } void Config::loadSavedQueries(const QString &configPath, QSettings &settings) { m_queriesPath = queriesPath(configPath); QFile file(m_queriesPath); if (!file.exists()) { // Migration. Read [queries] once, write the JSON, and leave the INI // section alone: stripping it would mean rewriting a hand-edited file // with QSettings, which drops comments and key order across the WHOLE // file. A few stale lines the user can delete by hand is the cheaper // loss, and it keeps a downgrade working. settings.beginGroup(QStringLiteral("queries")); const QStringList names = settings.childKeys(); for (const QString &name : names) { SavedQuery query; query.name = name; query.query = settings.value(name).toString(); // Pinned, because these are buttons today. A migration that left // them unpinned would empty the query row on the first launch // after an upgrade, which reads as data loss. query.pinned = true; m_savedQueries.append(query); } settings.endGroup(); // Sent was a hardcoded button beside the saved queries and becomes an // ordinary row here, so it can be reordered, renamed, unpinned or // removed like any other. It stays GENERATED, so it still follows the // accounts. Appended last, where the button already sat. // // Only when an account actually configures a sent folder: the button // was hidden entirely otherwise, and migrating a row that always finds // nothing would be worse than what it replaces. if (!allSentQuery().isEmpty()) { SavedQuery sent; sent.name = QStringLiteral("Sent"); sent.generated = QStringLiteral("sent"); sent.pinned = true; sent.flat = true; m_savedQueries.append(sent); } // Order is alphabetical here because childKeys() is genuinely all the // INI knows. The user reorders once and it sticks from then on. if (!m_savedQueries.isEmpty() && !saveSavedQueries()) { addProblem(QStringLiteral("Could not write saved queries to %1.") .arg(m_queriesPath)); } return; } if (!file.open(QIODevice::ReadOnly)) { addProblem(QStringLiteral("Could not read %1: %2.") .arg(m_queriesPath, file.errorString())); m_queriesRefused = true; return; } QJsonParseError error; const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error); file.close(); if (error.error != QJsonParseError::NoError || !document.isObject()) { addProblem(QStringLiteral("%1 is not valid JSON: %2.") .arg(m_queriesPath, error.errorString())); m_queriesRefused = true; return; } const QJsonObject root = document.object(); const int version = root.value(QStringLiteral("version")).toInt(kQueriesFormatVersion); if (version != kQueriesFormatVersion) { // Refused rather than guessed at, and the refusal blocks the save: // rewriting a newer document with this build's reading of it would // destroy whatever the newer build stored. addProblem(QStringLiteral("%1 has format version %2; this build " "understands %3. Saved queries were not " "loaded.") .arg(m_queriesPath) .arg(version) .arg(kQueriesFormatVersion)); m_queriesRefused = true; return; } for (auto it = root.begin(); it != root.end(); ++it) { if (it.key() != QStringLiteral("version") && it.key() != QStringLiteral("queries")) m_queriesUnknown.insert(it.key(), it.value()); } const QJsonArray array = root.value(QStringLiteral("queries")).toArray(); for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); SavedQuery query; query.name = object.value(QStringLiteral("name")).toString(); query.query = object.value(QStringLiteral("query")).toString(); query.pinned = object.value(QStringLiteral("pinned")).toBool(false); query.account = object.value(QStringLiteral("account")).toString(); query.generated = object.value(QStringLiteral("generated")).toString(); // A generator carries its own view mode, so "sent" is flat whether or // not the file says so. Storing it as a plain field would let a // hand-edited or migrated-from-elsewhere row produce a THREADED sent // view, which folds every reply back into the conversation the user // sent one message into. The file may still set it for an ordinary // query. query.flat = object.value(QStringLiteral("flat")).toBool(false) || query.generated == QStringLiteral("sent"); if (query.isGenerated() && !kQueryGenerators.contains(query.generated)) { // Reported but KEPT. A later build may know this generator, and // dropping the row here would delete it from the file on the next // save, which is the same data loss the unknown-field handling // exists to prevent. addProblem(QStringLiteral("Saved query '%1' uses an unknown " "generator '%2' and will find nothing.") .arg(query.name, query.generated)); } if (query.name.isEmpty()) { addProblem(QStringLiteral("A saved query in %1 has no name and was " "skipped.").arg(m_queriesPath)); continue; } for (auto it = object.begin(); it != object.end(); ++it) { static const QStringList known = { QStringLiteral("name"), QStringLiteral("query"), QStringLiteral("pinned"), QStringLiteral("account"), QStringLiteral("generated"), QStringLiteral("flat") }; if (!known.contains(it.key())) query.unknown.insert(it.key(), it.value()); } m_savedQueries.append(query); } } bool Config::saveSavedQueries() const { if (m_queriesPath.isEmpty() || m_queriesRefused) return false; QJsonArray array; for (const SavedQuery &query : m_savedQueries) { // Only what carries information. The file is hand-editable, so a key // that always holds the same value, or one the generator already // implies, is just something the reader has to skip past. Same reason // `pinned` and `account` are written only when set. QJsonObject object; object.insert(QStringLiteral("name"), query.name); if (query.isGenerated()) { object.insert(QStringLiteral("generated"), query.generated); } else { object.insert(QStringLiteral("query"), query.query); } if (query.pinned) object.insert(QStringLiteral("pinned"), true); if (!query.account.isEmpty()) object.insert(QStringLiteral("account"), query.account); // Skipped when the generator already implies it, which loadSavedQueries // reapplies on the way back in. if (query.flat && query.generated != QStringLiteral("sent")) object.insert(QStringLiteral("flat"), true); for (auto it = query.unknown.begin(); it != query.unknown.end(); ++it) object.insert(it.key(), it.value()); array.append(object); } QJsonObject root = m_queriesUnknown; root.insert(QStringLiteral("version"), kQueriesFormatVersion); root.insert(QStringLiteral("queries"), array); QDir().mkpath(QFileInfo(m_queriesPath).absolutePath()); // QSaveFile writes a temporary and renames on commit, so an interrupted // write cannot leave a half-written file where the queries used to be. QSaveFile file(m_queriesPath); if (!file.open(QIODevice::WriteOnly)) return false; file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); return file.commit(); } QString Config::resolvedQuery(const SavedQuery &query) const { // Composed from the accounts every time it is asked for, which is the // point: the answer follows the config rather than a copy of it taken when // the entry was written. if (query.isGenerated()) { if (query.generated == QStringLiteral("sent")) return allSentQuery(); // An unknown generator was reported on load. Empty rather than the // bare stored query, which for a generated entry is empty anyway and // would otherwise run as "match everything". return QString(); } if (query.account.isEmpty()) return query.query; const Account scope = account(query.account); if (!scope.isValid()) return query.query; return scope.scopedQuery(query.query); } bool Config::isKnownGenerator(const QString &generator) { return kQueryGenerators.contains(generator); } QString Config::matchNothingQuery() { // notmuch reads an EMPTY query as "match everything", so a generator with // nothing to match must say so explicitly. `tag:` and its negation cannot // both hold, and the tag name is irrelevant: what matters is that this // parses and matches nothing. A malformed string would not do, since // notmuch accepts almost anything and matches nothing quietly, which is the // same result reached by luck rather than by contract. return QStringLiteral("tag:unread and not tag:unread"); } QList Config::builtinFilters() { // Left to right on the query row. Fixed rather than configurable: item 94 // removes the mixed row entirely once these are confirmed, so a settings // surface for the order would be built and deleted inside two items. QList filters; for (const QString &generator : kQueryGenerators) filters.append(builtinFilter(generator)); return filters; } SavedQuery Config::builtinFilter(const QString &generator) { if (!isKnownGenerator(generator)) return {}; SavedQuery filter; filter.generated = generator; // Translated, because these are the labels on the buttons. The GENERATOR // name is not: it is stored in queries.json and matched against a closed // set, so translating it would make a file written in one locale unreadable // in another. if (generator == QStringLiteral("unread")) { filter.name = tr("Unread"); } else if (generator == QStringLiteral("inbox")) { filter.name = tr("Inbox"); } else if (generator == QStringLiteral("flagged")) { filter.name = tr("Flagged"); } else if (generator == QStringLiteral("sent")) { filter.name = tr("Sent"); // Messages rather than threads, and the only filter that sets this. A // thread would fold the user's sent message back into the conversation // it belongs to, which is item 63's finding. filter.flat = true; } return filter; } QString Config::resolvedQuery(const SavedQuery &query, const QString &accountKey) const { // An ordinary saved query is a DESTINATION: it states its own scope and // ignores the dropdown, which is the behaviour item 90 leaves alone. Only a // generated filter composes. if (!query.isGenerated()) return resolvedQuery(query); if (!isKnownGenerator(query.generated)) return QString(); if (accountKey.isEmpty()) { // Across every account, which for the tag filters is the bare query and // for Sent is the union of the accounts' folders. if (query.generated == QStringLiteral("sent")) { const QString all = allSentQuery(); return all.isEmpty() ? matchNothingQuery() : all; } return QStringLiteral("tag:%1").arg(generatorTag(query.generated)); } const Account scope = account(accountKey); if (!scope.isValid()) return resolvedQuery(query, QString()); if (query.generated == QStringLiteral("sent")) { // The account's OWN sent query, never the all-accounts one wrapped in // this account's path. Wrapping gives // path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**") // which returns the right rows because path: is hierarchical, and is // still wrong: it double-scopes and works by accident of the syntax. const QString sent = scope.sentQuery(); // Empty when the account configures no sent folder, which is a real // case and not a misconfiguration. Returned as-is it would mean "match // everything", so a button labelled Sent would show the whole Maildir. return sent.isEmpty() ? matchNothingQuery() : sent; } // A tag filter carries no path of its own, so scoping is exactly what // scopedQuery() does. Its parentheses are load-bearing: `path:... and a or // b` binds as `(path:... and a) or b`. return scope.scopedQuery( QStringLiteral("tag:%1").arg(generatorTag(query.generated))); } SavedQuery Config::startupSavedQuery() const { if (m_savedQueries.isEmpty()) return {}; for (const SavedQuery &query : m_savedQueries) { if (query.name.compare(m_startupQuery, Qt::CaseInsensitive) == 0) return query; } // Named a query that does not exist. Not worth a warning: the default is // a name the user never wrote, so an install with no [queries] Unread // entry would warn on every launch about a key it never set. return m_savedQueries.first(); } Account Config::account(const QString &key) const { for (const Account &a : m_accounts) { if (a.key == key) return a; } return {}; }