From c3cfd28844c34aa7f7b390215f73d0b56534bdd0 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 26 Aug 2026 15:42:12 +0200 Subject: feat: propose business-sender candidates, always commented out --- src/businesssenders.cpp | 81 +++++++++++++++++++++++++++++++++ src/businesssenders.h | 30 +++++++++++++ tests/test_businesssenders.cpp | 100 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+) diff --git a/src/businesssenders.cpp b/src/businesssenders.cpp index 13a9374..06f4d43 100644 --- a/src/businesssenders.cpp +++ b/src/businesssenders.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include @@ -75,4 +76,84 @@ QString defaultPath() QStringLiteral("qtmaildir/business-senders")); } +bool looksLikeBulk(const QString &address) +{ + static const QStringList kBulkLocalParts { + QStringLiteral("noreply"), QStringLiteral("no-reply"), + QStringLiteral("donotreply"), QStringLiteral("do-not-reply"), + QStringLiteral("info"), QStringLiteral("support"), + QStringLiteral("billing"), QStringLiteral("newsletter"), + QStringLiteral("notifications"), QStringLiteral("mailer-daemon"), + }; + const int at = address.indexOf(QLatin1Char('@')); + if (at <= 0) + return false; + const QString local = address.left(at).toLower(); + for (const QString &candidate : kBulkLocalParts) { + if (local == candidate || local.startsWith(candidate)) + return true; + } + return false; +} + +void appendCandidates(const QString &path, const QHash &counts) +{ + // Every address the file MENTIONS, active or rejected. Parsed separately + // from parse() above, which deliberately drops comments: here a comment is + // exactly what must be remembered. + QSet mentioned; + QFile existing(path); + if (existing.open(QIODevice::ReadOnly | QIODevice::Text)) { + const QStringList lines = + QString::fromUtf8(existing.readAll()).split(QLatin1Char('\n')); + for (const QString &raw : lines) { + QString line = raw.trimmed(); + if (line.startsWith(QLatin1Char('#'))) + line = line.mid(1).trimmed(); + if (line.isEmpty()) + continue; + // "noreply@cofidis.it (47 messages)" mentions the address before + // its count. + mentioned.insert(line.section(QLatin1Char(' '), 0, 0).toLower()); + } + existing.close(); + } + + QStringList additions; + for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) { + const QString address = it.key().trimmed().toLower(); + if (address.isEmpty() || mentioned.contains(address)) + continue; + if (!looksLikeBulk(address)) + continue; + additions.append(QStringLiteral("# %1 (%2 messages)") + .arg(address) + .arg(it.value())); + } + if (additions.isEmpty()) + return; + + additions.sort(); + + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile file(path); + if (!file.open(QIODevice::Append | QIODevice::Text)) + return; + QTextStream out(&file); + for (const QString &line : additions) + out << line << '\n'; +} + +QString scanQuery(const QString &path) +{ + // "*" is notmuch's match-everything. An EMPTY string would also match + // everything, which is why Config::matchNothingQuery() exists elsewhere in + // this codebase; being explicit here means a reader never has to wonder + // which of the two an empty return meant. + const List existing = load(path); + if (existing.addresses.isEmpty() && existing.domains.isEmpty()) + return QStringLiteral("*"); + return QStringLiteral("date:1week.."); +} + } // namespace BusinessSenders diff --git a/src/businesssenders.h b/src/businesssenders.h index 0689453..3065ae5 100644 --- a/src/businesssenders.h +++ b/src/businesssenders.h @@ -18,6 +18,7 @@ #pragma once +#include #include #include #include @@ -55,4 +56,33 @@ bool contains(const List &list, const QString &address); /// QStandardPaths::GenericConfigLocation. QString defaultPath(); +/// True when a local part looks like bulk mail rather than a person. +/// +/// A GUESS, and openly one. It misses senders and proposes wrong ones, which +/// is exactly why nothing it produces takes effect until the user uncomments +/// it. +bool looksLikeBulk(const QString &address); + +/// Appends anything in `counts` that looks like bulk and is not already in the +/// file, COMMENTED OUT, with its message count. +/// +/// Two rules, both load-bearing. It never writes an uncommented entry, so +/// nothing on screen changes until the user acts. And it skips an address +/// already present in ANY form, commented or not, so an entry the user +/// rejected is never re-proposed, and one they deleted only returns if that +/// sender writes again. +void appendCandidates(const QString &path, const QHash &counts); + +/// The query the candidate scan should run. +/// +/// A week of mail once the file exists, so the step stays incremental and +/// cheap. EVERYTHING when the file is missing or holds no entries, because +/// that is the first run: a week's mail proposes almost nothing, and the file +/// would then take months to become useful. The whole-database scan is +/// affordable precisely because it happens once, measured at 76 ms over 5105 +/// messages. +/// +/// Returns notmuch query syntax, which is wire format and is never translated. +QString scanQuery(const QString &path); + } // namespace BusinessSenders diff --git a/tests/test_businesssenders.cpp b/tests/test_businesssenders.cpp index 64a96fa..bc6a5f6 100644 --- a/tests/test_businesssenders.cpp +++ b/tests/test_businesssenders.cpp @@ -16,6 +16,7 @@ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ +#include #include #include @@ -32,6 +33,11 @@ private slots: void whitespaceAroundAnEntryIsIgnored(); void matchingIsCaseInsensitive(); void anAbsentFileMatchesNothing(); + void candidatesAreAppendedCommentedOut(); + void anAddressAlreadyPresentIsNeverReproposed(); + void onlyBulkLookingLocalPartsAreProposed(); + void theFirstRunScansEverything(); + void alaterRunScansOnlyRecentMail(); }; void TestBusinessSenders::anExactAddressMatches() @@ -97,5 +103,99 @@ void TestBusinessSenders::anAbsentFileMatchesNothing() QVERIFY(!BusinessSenders::contains(list, QStringLiteral("a@example.org"))); } +void TestBusinessSenders::candidatesAreAppendedCommentedOut() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("business-senders")); + + QHash counts; + counts.insert(QStringLiteral("noreply@cofidis.it"), 47); + BusinessSenders::appendCandidates(path, counts); + + QFile file(path); + QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString written = QString::fromUtf8(file.readAll()); + + // Commented, and carrying the count so the user can judge it. + QVERIFY(written.contains(QStringLiteral("# noreply@cofidis.it"))); + QVERIFY(written.contains(QStringLiteral("47"))); + + // Nothing it wrote may take effect on its own. + const BusinessSenders::List list = BusinessSenders::load(path); + QVERIFY(!BusinessSenders::contains(list, + QStringLiteral("noreply@cofidis.it"))); +} + +void TestBusinessSenders::anAddressAlreadyPresentIsNeverReproposed() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("business-senders")); + + // Both forms count as present: an active entry and a rejected one. The + // rejected case is the one that matters, since re-proposing it would undo + // the user's decision every ten minutes with no explanation. + QFile seed(path); + QVERIFY(seed.open(QIODevice::WriteOnly | QIODevice::Text)); + seed.write("billing@example.org\n# noreply@cofidis.it (47 messages)\n"); + seed.close(); + const qint64 sizeBefore = QFileInfo(path).size(); + + QHash counts; + counts.insert(QStringLiteral("noreply@cofidis.it"), 51); + counts.insert(QStringLiteral("billing@example.org"), 12); + BusinessSenders::appendCandidates(path, counts); + + QCOMPARE(QFileInfo(path).size(), sizeBefore); +} + +void TestBusinessSenders::onlyBulkLookingLocalPartsAreProposed() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("business-senders")); + + QHash counts; + counts.insert(QStringLiteral("noreply@a.org"), 3); + counts.insert(QStringLiteral("john.doe@b.org"), 3); + BusinessSenders::appendCandidates(path, counts); + + QFile file(path); + QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString written = QString::fromUtf8(file.readAll()); + QVERIFY(written.contains(QStringLiteral("noreply@a.org"))); + QVERIFY(!written.contains(QStringLiteral("john.doe@b.org"))); +} + +void TestBusinessSenders::theFirstRunScansEverything() +{ + QTemporaryDir dir; + const QString missing = dir.filePath(QStringLiteral("business-senders")); + + // No file at all: a week of mail would propose almost nothing and the + // list would take months to become useful, so the first run pays for a + // full scan once. + QCOMPARE(BusinessSenders::scanQuery(missing), QStringLiteral("*")); + + // A file holding ONLY rejected candidates is still a first run: nothing + // has been accepted yet. Rescanning re-proposes none of them, since + // appendCandidates skips anything already mentioned. + QFile rejected(missing); + QVERIFY(rejected.open(QIODevice::WriteOnly | QIODevice::Text)); + rejected.write("# noreply@cofidis.it (47 messages)\n"); + rejected.close(); + QCOMPARE(BusinessSenders::scanQuery(missing), QStringLiteral("*")); +} + +void TestBusinessSenders::alaterRunScansOnlyRecentMail() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("business-senders")); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write("billing@example.org\n"); + file.close(); + + QCOMPARE(BusinessSenders::scanQuery(path), QStringLiteral("date:1week..")); +} + QTEST_MAIN(TestBusinessSenders) #include "test_businesssenders.moc" -- cgit v1.2.3