/* * 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 "rulequery.h" #include #include #include namespace { /// The notmuch prefix each field compiles to. Wire format, never translated. QString prefixFor(RuleTerm::Field field) { switch (field) { case RuleTerm::From: return QStringLiteral("from"); case RuleTerm::To: return QStringLiteral("to"); case RuleTerm::Cc: return QStringLiteral("cc"); case RuleTerm::Subject: return QStringLiteral("subject"); case RuleTerm::Tag: return QStringLiteral("tag"); case RuleTerm::Folder: return QStringLiteral("path"); case RuleTerm::Attachment: return QStringLiteral("attachment"); case RuleTerm::Date: return QStringLiteral("date"); } return QString(); } bool isNegated(RuleTerm::Op op) { return op == RuleTerm::ContainsNot || op == RuleTerm::IsNot || op == RuleTerm::HasNot; } /// Quoted when the operator asks for an exact phrase, and ALWAYS when the /// value holds a space: unquoted, the space ends the term and the remainder /// becomes a bare word, which widens the rule instead of breaking it. bool needsQuotes(const RuleTerm &term) { if (term.field == RuleTerm::Folder) return true; if (term.value.contains(QLatin1Char(' '))) return true; // Is/IsNot means an exact phrase, and only the free-text fields need // quotes to express one. A tag or an attachment name is a single bare // token to notmuch, which reads `tag:inbox` and `tag:"inbox"` identically // (both count 5322 against the live index). Quoting them would therefore // change the stored string without changing what it matches, and this // type's whole contract is that an unedited rule compiles back byte for // byte. if (term.op == RuleTerm::Is || term.op == RuleTerm::IsNot) { return term.field == RuleTerm::From || term.field == RuleTerm::To || term.field == RuleTerm::Cc || term.field == RuleTerm::Subject; } return false; } QString compileTerm(const RuleTerm &term) { QString value = term.value; if (term.field == RuleTerm::Folder) value += QStringLiteral("/**"); QString body; if (term.field == RuleTerm::Date) { body = prefixFor(term.field) + QLatin1Char(':') + (term.op == RuleTerm::Before ? QStringLiteral("..") + value : value + QStringLiteral("..")); } else if (needsQuotes(term)) { body = prefixFor(term.field) + QStringLiteral(":\"") + value + QLatin1Char('"'); } else { body = prefixFor(term.field) + QLatin1Char(':') + value; } return isNegated(term.op) ? QStringLiteral("not ") + body : body; } /// Splits on whitespace, keeping a double-quoted run as one token. Returns /// false when a quote is left open, which is a query this builder will not /// represent. bool tokenise(const QString &query, QStringList *out) { QString current; bool inQuotes = false; bool has = false; for (int i = 0; i < query.size(); ++i) { const QChar c = query.at(i); if (c == QLatin1Char('"')) { inQuotes = !inQuotes; current += c; has = true; } else if (!inQuotes && c.isSpace()) { if (has) { out->append(current); current.clear(); has = false; } } else { current += c; has = true; } } if (inQuotes) return false; if (has) out->append(current); return true; } bool fieldForPrefix(const QString &prefix, RuleTerm::Field *out) { static const QVector> table = { {QStringLiteral("from"), RuleTerm::From}, {QStringLiteral("to"), RuleTerm::To}, {QStringLiteral("cc"), RuleTerm::Cc}, {QStringLiteral("subject"), RuleTerm::Subject}, {QStringLiteral("tag"), RuleTerm::Tag}, {QStringLiteral("path"), RuleTerm::Folder}, {QStringLiteral("attachment"), RuleTerm::Attachment}, {QStringLiteral("date"), RuleTerm::Date}, }; for (const auto &entry : table) { if (entry.first == prefix) { *out = entry.second; return true; } } return false; } /// Parses ONE token into a term. Returns false for anything this builder does /// not represent, which is not the same as invalid: notmuch accepts far more /// than this. bool parseTerm(const QString &token, RuleTerm *out) { const int colon = token.indexOf(QLatin1Char(':')); if (colon <= 0) return false; RuleTerm::Field field; if (!fieldForPrefix(token.left(colon), &field)) return false; QString value = token.mid(colon + 1); if (value.isEmpty()) return false; bool quoted = false; if (value.size() >= 2 && value.startsWith(QLatin1Char('"')) && value.endsWith(QLatin1Char('"'))) { value = value.mid(1, value.size() - 2); quoted = true; } // A quote anywhere else means a shape this builder does not emit. if (value.contains(QLatin1Char('"'))) return false; out->field = field; if (field == RuleTerm::Date) { if (value.startsWith(QStringLiteral(".."))) { out->op = RuleTerm::Before; out->value = value.mid(2); } else if (value.endsWith(QStringLiteral(".."))) { out->op = RuleTerm::After; out->value = value.chopped(2); } else { return false; // A two-sided range is not a row. } return !out->value.isEmpty(); } if (field == RuleTerm::Folder) { // Only the recursive form is representable; a bare path means // something different to notmuch and must not be silently rewritten. if (!value.endsWith(QStringLiteral("/**"))) return false; value = value.chopped(3); out->op = RuleTerm::Is; out->value = value; return !value.isEmpty(); } // Tag and Attachment compile unquoted (see needsQuotes), so their // operator must not be inferred from the quoting: reading a quoted tag // back as a quoting operator would compile it unquoted and change the // stored string. if (field == RuleTerm::Attachment) out->op = RuleTerm::Has; else if (field == RuleTerm::Tag) out->op = RuleTerm::Is; else out->op = quoted ? RuleTerm::Is : RuleTerm::Contains; out->value = value; return true; } /// Splits `(A or B) and not C and not D` into its group and its remainder. /// Returns false when the query does not start with a balanced group. bool splitLeadingGroup(const QString &query, QString *group, QString *rest) { if (!query.startsWith(QLatin1Char('('))) return false; int depth = 0; bool inQuotes = false; for (int i = 0; i < query.size(); ++i) { const QChar c = query.at(i); if (c == QLatin1Char('"')) inQuotes = !inQuotes; if (inQuotes) continue; if (c == QLatin1Char('(')) ++depth; else if (c == QLatin1Char(')')) { --depth; if (depth == 0) { *group = query.mid(1, i - 1).trimmed(); *rest = query.mid(i + 1).trimmed(); return true; } } } return false; } } // namespace bool operator==(const RuleTerm &a, const RuleTerm &b) { return a.field == b.field && a.op == b.op && a.value == b.value; } bool operator==(const RuleQuery &a, const RuleQuery &b) { return a.parsed == b.parsed && a.join == b.join && a.terms == b.terms && a.exclusions == b.exclusions; } QString RuleQuery::compile() const { if (terms.isEmpty()) return QString(); QStringList parts; for (const RuleTerm &term : terms) parts.append(compileTerm(term)); const QString glue = join == Any ? QStringLiteral(" or ") : QStringLiteral(" and "); QString out = parts.join(glue); // An `or` group followed by `and not` must be parenthesised or the `and` // binds tighter than the `or`: `a or b and not c` is `a or (b and not c)`, // which matches every `a` whatever the exclusion says. if (join == Any && !exclusions.isEmpty() && terms.size() > 1) out = QLatin1Char('(') + out + QLatin1Char(')'); for (const RuleTerm &term : exclusions) { // The block IS the negation, so its rows are stored un-negated and // the `and not` is applied here. A row stored negated would compile // to `and not not subject:x`. out += QStringLiteral(" and not ") + compileTerm(term); } return out; } RuleQuery RuleQuery::parse(const QString &query) { RuleQuery out; const QString trimmed = query.trimmed(); if (trimmed.isEmpty()) { // An empty query is a rule with no rows yet, not a failure. out.parsed = true; return out; } QString group; QString rest; if (splitLeadingGroup(trimmed, &group, &rest)) { // Only one nested shape is representable: an `or` group followed by // `and not` exclusions. Anything else rejects whole. if (group.contains(QLatin1Char('('))) return RuleQuery(); const RuleQuery inner = parse(group); if (!inner.parsed || inner.join != Any || !inner.exclusions.isEmpty()) return RuleQuery(); out.join = Any; out.terms = inner.terms; if (rest.isEmpty()) { // A group with nothing after it compiles back WITHOUT parens, // since compile() only adds them when exclusions follow. Round // trip would break, so this is not representable. return RuleQuery(); } // The remainder must be nothing but `and not ` repetitions. QStringList tail; if (!tokenise(rest, &tail)) return RuleQuery(); int i = 0; while (i < tail.size()) { if (tail.at(i).compare(QStringLiteral("and"), Qt::CaseInsensitive) != 0) return RuleQuery(); ++i; if (i >= tail.size() || tail.at(i).compare(QStringLiteral("not"), Qt::CaseInsensitive) != 0) return RuleQuery(); ++i; if (i >= tail.size()) return RuleQuery(); RuleTerm term; if (!parseTerm(tail.at(i), &term)) return RuleQuery(); out.exclusions.append(term); ++i; } out.parsed = true; return out; } QStringList tokens; if (!tokenise(trimmed, &tokens)) return RuleQuery(); // Walk the chain: term, operator, term, ... Anything else rejects whole. bool sawOr = false; bool sawAnd = false; int i = 0; while (i < tokens.size()) { bool negated = false; if (tokens.at(i).compare(QStringLiteral("not"), Qt::CaseInsensitive) == 0) { negated = true; ++i; if (i >= tokens.size()) return RuleQuery(); } RuleTerm term; if (!parseTerm(tokens.at(i), &term)) return RuleQuery(); if (negated) { // `not date:` has no row form: "not before" is "after", which the // unnegated operators already express. if (term.field == RuleTerm::Date) return RuleQuery(); // The block IS the negation, so the row is stored un-negated and // compile() re-applies the `and not`. out.exclusions.append(term); } else { out.terms.append(term); } ++i; if (i >= tokens.size()) break; const QString glue = tokens.at(i).toLower(); if (glue == QStringLiteral("and")) { sawAnd = true; } else if (glue == QStringLiteral("or")) { sawOr = true; } else { return RuleQuery(); // Not a joining word: unrepresentable. } ++i; if (i >= tokens.size()) return RuleQuery(); // Trailing operator. } // Mixed and/or without parentheses is ambiguous to a reader and binds in // a way the rows cannot show. Reject rather than guess. if (sawAnd && sawOr) return RuleQuery(); if (out.terms.isEmpty()) return RuleQuery(); out.join = sawOr ? Any : All; out.parsed = true; return out; }