/* * 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 #include "rulequery.h" /// RuleQuery is a view over a string the notmuch post-new hook executes, so /// the risk is a query that compiles to something subtly wider than the rows /// say. These tests are about exact strings, not about whether notmuch would /// accept the result: notmuch accepts almost anything, including `from:((((`. class TestRuleQuery : public QObject { Q_OBJECT private slots: void aSingleContainsTermCompiles(); void everyFieldCompilesToItsPrefix(); void isQuotesAndContainsDoesNot(); void negationPrefixesNot(); void aValueWithASpaceIsAlwaysQuoted(); void folderAppendsTheRecursiveSuffix(); void dateCompilesToAOneSidedRange(); void allJoinsWithAnd(); void anyJoinsWithOr(); void exclusionsAppendAsAndNot(); void anyIsParenthesisedOnlyWhenExclusionsFollow(); void anEmptyQueryCompilesToAnEmptyString(); void aFlatAndChainParses(); void aFlatOrChainParses(); void anEmptyQueryParsesToNoRows(); void quotedValuesLoseTheirQuotes(); void aNegatedTermParsesAsANegatedOperator(); void whatParsesCompilesBackUnchanged(); void anOrGroupWithExclusionsParses(); void anUnrepresentableQueryRejectsWhole(); void aParenBearingValueIsARowNotAShape(); void theRuleCorpusRoundTripsByteForByte(); }; void TestRuleQuery::aSingleContainsTermCompiles() { RuleQuery q; q.terms.append({RuleTerm::From, RuleTerm::Contains, QStringLiteral("sender@example.org")}); QCOMPARE(q.compile(), QStringLiteral("from:sender@example.org")); } void TestRuleQuery::everyFieldCompilesToItsPrefix() { const QVector> cases = { {RuleTerm::From, QStringLiteral("from:x")}, {RuleTerm::To, QStringLiteral("to:x")}, {RuleTerm::Cc, QStringLiteral("cc:x")}, {RuleTerm::Subject, QStringLiteral("subject:x")}, }; for (const auto &c : cases) { RuleQuery q; q.terms.append({c.first, RuleTerm::Contains, QStringLiteral("x")}); QCOMPARE(q.compile(), c.second); } } void TestRuleQuery::isQuotesAndContainsDoesNot() { RuleQuery contains; contains.terms.append({RuleTerm::Subject, RuleTerm::Contains, QStringLiteral("receipt")}); QCOMPARE(contains.compile(), QStringLiteral("subject:receipt")); RuleQuery is; is.terms.append({RuleTerm::Subject, RuleTerm::Is, QStringLiteral("receipt")}); QCOMPARE(is.compile(), QStringLiteral("subject:\"receipt\"")); } void TestRuleQuery::negationPrefixesNot() { RuleQuery q; q.terms.append({RuleTerm::Subject, RuleTerm::ContainsNot, QStringLiteral("receipt")}); QCOMPARE(q.compile(), QStringLiteral("not subject:receipt")); RuleQuery tag; tag.terms.append({RuleTerm::Tag, RuleTerm::IsNot, QStringLiteral("inbox")}); QCOMPARE(tag.compile(), QStringLiteral("not tag:inbox")); RuleQuery att; att.terms.append({RuleTerm::Attachment, RuleTerm::HasNot, QStringLiteral("pdf")}); QCOMPARE(att.compile(), QStringLiteral("not attachment:pdf")); } void TestRuleQuery::aValueWithASpaceIsAlwaysQuoted() { // Unquoted, a space would end the term and the rest would become a // separate bare word, silently widening the rule. RuleQuery q; q.terms.append({RuleTerm::Subject, RuleTerm::Contains, QStringLiteral("your receipt")}); QCOMPARE(q.compile(), QStringLiteral("subject:\"your receipt\"")); } void TestRuleQuery::folderAppendsTheRecursiveSuffix() { // A path: without the suffix matches nothing, and notmuch reports no // error when it happens. RuleQuery q; q.terms.append({RuleTerm::Folder, RuleTerm::Is, QStringLiteral("account-one")}); QCOMPARE(q.compile(), QStringLiteral("path:\"account-one/**\"")); } void TestRuleQuery::dateCompilesToAOneSidedRange() { RuleQuery before; before.terms.append({RuleTerm::Date, RuleTerm::Before, QStringLiteral("2026-01-01")}); QCOMPARE(before.compile(), QStringLiteral("date:..2026-01-01")); RuleQuery after; after.terms.append({RuleTerm::Date, RuleTerm::After, QStringLiteral("2026-01-01")}); QCOMPARE(after.compile(), QStringLiteral("date:2026-01-01..")); } void TestRuleQuery::allJoinsWithAnd() { RuleQuery q; q.join = RuleQuery::All; q.terms.append({RuleTerm::From, RuleTerm::Contains, QStringLiteral("vendor.example.org")}); q.terms.append({RuleTerm::Subject, RuleTerm::Contains, QStringLiteral("receipt")}); QCOMPARE(q.compile(), QStringLiteral("from:vendor.example.org and subject:receipt")); } void TestRuleQuery::anyJoinsWithOr() { RuleQuery q; q.join = RuleQuery::Any; q.terms.append({RuleTerm::From, RuleTerm::Contains, QStringLiteral("one.example.org")}); q.terms.append({RuleTerm::From, RuleTerm::Contains, QStringLiteral("two.example.org")}); QCOMPARE(q.compile(), QStringLiteral("from:one.example.org or from:two.example.org")); } void TestRuleQuery::exclusionsAppendAsAndNot() { RuleQuery q; q.join = RuleQuery::All; q.terms.append({RuleTerm::From, RuleTerm::Contains, QStringLiteral("vendor.example.org")}); q.exclusions.append({RuleTerm::Subject, RuleTerm::Contains, QStringLiteral("receipt")}); QCOMPARE(q.compile(), QStringLiteral("from:vendor.example.org " "and not subject:receipt")); } void TestRuleQuery::anyIsParenthesisedOnlyWhenExclusionsFollow() { // Without the parens this binds as (a or (b and not c)), which matches // everything from the first sender regardless of the exclusion. RuleQuery guarded; guarded.join = RuleQuery::Any; guarded.terms.append({RuleTerm::From, RuleTerm::Contains, QStringLiteral("one.example.org")}); guarded.terms.append({RuleTerm::From, RuleTerm::Contains, QStringLiteral("two.example.org")}); guarded.exclusions.append({RuleTerm::Subject, RuleTerm::Contains, QStringLiteral("receipt")}); QCOMPARE(guarded.compile(), QStringLiteral("(from:one.example.org or from:two.example.org) " "and not subject:receipt")); // No exclusion, no parens: they would be noise in the stored file. RuleQuery bare; bare.join = RuleQuery::Any; bare.terms.append({RuleTerm::From, RuleTerm::Contains, QStringLiteral("one.example.org")}); bare.terms.append({RuleTerm::From, RuleTerm::Contains, QStringLiteral("two.example.org")}); QCOMPARE(bare.compile(), QStringLiteral("from:one.example.org or from:two.example.org")); } void TestRuleQuery::anEmptyQueryCompilesToAnEmptyString() { RuleQuery q; QCOMPARE(q.compile(), QString()); } void TestRuleQuery::aFlatAndChainParses() { const RuleQuery q = RuleQuery::parse( QStringLiteral("from:vendor.example.org and subject:receipt")); QVERIFY(q.parsed); QCOMPARE(q.join, RuleQuery::All); QCOMPARE(q.terms.size(), 2); QCOMPARE(q.terms.at(0).field, RuleTerm::From); QCOMPARE(q.terms.at(0).op, RuleTerm::Contains); QCOMPARE(q.terms.at(0).value, QStringLiteral("vendor.example.org")); QCOMPARE(q.terms.at(1).field, RuleTerm::Subject); QVERIFY(q.exclusions.isEmpty()); } void TestRuleQuery::aFlatOrChainParses() { const RuleQuery q = RuleQuery::parse( QStringLiteral("from:one.example.org or from:two.example.org")); QVERIFY(q.parsed); QCOMPARE(q.join, RuleQuery::Any); QCOMPARE(q.terms.size(), 2); } void TestRuleQuery::anEmptyQueryParsesToNoRows() { // One shipped rule has an empty query. It must open in the builder ready // to receive a row, not fall back to text mode. const RuleQuery q = RuleQuery::parse(QString()); QVERIFY(q.parsed); QVERIFY(q.terms.isEmpty()); } void TestRuleQuery::quotedValuesLoseTheirQuotes() { const RuleQuery q = RuleQuery::parse( QStringLiteral("subject:\"your receipt\"")); QVERIFY(q.parsed); QCOMPARE(q.terms.size(), 1); QCOMPARE(q.terms.at(0).value, QStringLiteral("your receipt")); QCOMPARE(q.terms.at(0).op, RuleTerm::Is); } void TestRuleQuery::aNegatedTermParsesAsANegatedOperator() { const RuleQuery q = RuleQuery::parse( QStringLiteral("from:vendor.example.org and not tag:inbox")); QVERIFY(q.parsed); // A trailing negation on an `and` chain becomes an exclusion: that is how // the user describes these rules, and the design records the preference. QCOMPARE(q.terms.size(), 1); QCOMPARE(q.exclusions.size(), 1); QCOMPARE(q.exclusions.at(0).field, RuleTerm::Tag); QCOMPARE(q.exclusions.at(0).op, RuleTerm::Is); } void TestRuleQuery::whatParsesCompilesBackUnchanged() { // The dialog decides whether to rewrite the stored string by comparing // against what it parsed, so a compile that differs by so much as a quote // would churn a file a second tool reads. const QStringList queries = { QStringLiteral("from:vendor.example.org"), QStringLiteral("from:vendor.example.org and subject:receipt"), QStringLiteral("from:one.example.org or from:two.example.org"), QStringLiteral("subject:\"your receipt\""), QStringLiteral("path:\"account-one/**\""), QStringLiteral("tag:inbox"), QStringLiteral("attachment:pdf"), QStringLiteral("date:..2026-01-01"), QStringLiteral("date:2026-01-01.."), QStringLiteral("from:vendor.example.org and not tag:inbox"), QStringLiteral("from:vendor.example.org and not subject:receipt " "and not subject:refund"), QStringLiteral("(from:one.example.org or from:two.example.org) " "and not subject:receipt"), }; for (const QString &query : queries) { const RuleQuery parsed = RuleQuery::parse(query); QVERIFY2(parsed.parsed, qPrintable(query)); QCOMPARE(parsed.compile(), query); } } void TestRuleQuery::anOrGroupWithExclusionsParses() { const RuleQuery q = RuleQuery::parse( QStringLiteral("(from:vendor.example.org or from:vendor.example.net) " "and not subject:receipt and not subject:refund")); QVERIFY(q.parsed); QCOMPARE(q.join, RuleQuery::Any); QCOMPARE(q.terms.size(), 2); QCOMPARE(q.exclusions.size(), 2); QCOMPARE(q.exclusions.at(0).field, RuleTerm::Subject); QCOMPARE(q.exclusions.at(0).op, RuleTerm::Contains); QCOMPARE(q.exclusions.at(0).value, QStringLiteral("receipt")); // The round trip is the point: this must come back as it went in. QCOMPARE(q.compile(), QStringLiteral("(from:vendor.example.org or " "from:vendor.example.net) " "and not subject:receipt and not subject:refund")); } void TestRuleQuery::anUnrepresentableQueryRejectsWhole() { const QStringList unrepresentable = { QStringLiteral("from:a.example.org or (from:b.example.org " "or from:c.example.org)"), // nested or inside or QStringLiteral("from:a.example.org and subject:x " "or subject:y"), // mixed, unparenthesised QStringLiteral("body:receipt"), // unrecognised prefix QStringLiteral("folder:Inbox"), // not the prefix we emit QStringLiteral("receipt"), // bare word, no prefix QStringLiteral("path:\"account-one\""), // no /** suffix QStringLiteral("from:a.example.org and"), // trailing operator QStringLiteral("date:2026-01-01..2026-02-01"), // two-sided range QStringLiteral("from:a.example.org xor subject:x"), QStringLiteral("subject:\"unterminated"), // open quote QStringLiteral("(from:a.example.org or from:b.example.org)"), // group, nothing after it QStringLiteral("(from:a.example.org and from:b.example.org) " "and not subject:x"), // group joined by and }; for (const QString &query : unrepresentable) { const RuleQuery q = RuleQuery::parse(query); QVERIFY2(!q.parsed, qPrintable(QStringLiteral("parsed: ") + query)); // Rejecting whole means keeping nothing: a half-parse is how a // negation goes missing and a rule quietly matches more mail. QVERIFY2(q.terms.isEmpty() && q.exclusions.isEmpty(), qPrintable(QStringLiteral("kept rows: ") + query)); } } void TestRuleQuery::aParenBearingValueIsARowNotAShape() { // notmuch reads these parens as characters to search for, not as // grouping, so the query is meaningful and matches nothing. It parses // here as a From row holding that literal text, which is what it means. // The property to hold is the round trip, NOT a rejection: a test // expecting an error here fails against correct code. const RuleQuery q = RuleQuery::parse(QStringLiteral("from:((((")); QVERIFY(q.parsed); QCOMPARE(q.terms.size(), 1); QCOMPARE(q.terms.at(0).value, QStringLiteral("((((")); QCOMPARE(q.compile(), QStringLiteral("from:((((")); } void TestRuleQuery::theRuleCorpusRoundTripsByteForByte() { // Every query SHAPE present in a real rules file, with placeholder // values. Verified by hand against the live file when this was written: // all seventeen rules parsed and compiled back byte for byte, none fell // to text mode. // // A compile that differs by so much as a paren would rewrite the shared // file on the next save, which the companion tool then sees as a diff // nobody made. const QStringList corpus = { QString(), // a rule with no query yet QStringLiteral("path:\"account-one/**\""), QStringLiteral("path:\"account-two/Inbox/topic/**\""), QStringLiteral("subject:\"[list-name]\""), QStringLiteral("from:notifications@service.example.org"), QStringLiteral("from:one@jobs.example.org or " "from:two@jobs.example.org or " "from:three@jobs.example.org"), QStringLiteral("from:mail.vendor.example.org and " "subject:\"Secure link\""), QStringLiteral("(from:vendor.example.org or from:vendor.example.net) " "and not subject:receipt and not subject:refund " "and not subject:EUR"), }; for (const QString &query : corpus) { const RuleQuery parsed = RuleQuery::parse(query); QVERIFY2(parsed.parsed, qPrintable(query)); QCOMPARE(parsed.compile(), query); // And the value itself round-trips, which is what the dialog's // "was this edited" comparison depends on. QVERIFY2(RuleQuery::parse(parsed.compile()) == parsed, qPrintable(query)); } } QTEST_MAIN(TestRuleQuery) #include "test_rulequery.moc"