aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-01 10:31:51 +0200
committerDanilo M. <danix@danix.xyz>2026-10-01 10:31:51 +0200
commitb12aada52d682c45aa1e66e13dc448dc894aee79 (patch)
treee41ded20c908ee458e44171c6f4f7088ee613639
parent236ac86ca09a24300e724c2372f3576bf3f6ff84 (diff)
downloadqtmaildir-b12aada52d682c45aa1e66e13dc448dc894aee79.tar.gz
qtmaildir-b12aada52d682c45aa1e66e13dc448dc894aee79.zip
fix: double a quote in a search term, as notmuch expects
SearchTerm::quote() escaped an embedded double quote with a backslash. notmuch has no backslash escape inside a quoted term: it doubles the quote, and a backslash-escaped one ENDS the term. Selected text holding a quote therefore let the rest of the selection reach the query as syntax. Measured on a throwaway index: under a `tag:nomatch and` scope, the backslash form of `x " or tag:inbox or id:"` matched every message, while the doubled form matched none. quote() now doubles the quote and leaves a backslash alone, since notmuch reads it literally. Truncation still runs first, so a cut cannot split a doubled pair. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--AGENTS.md8
-rw-r--r--CHANGELOG.md6
-rw-r--r--src/searchterm.cpp11
-rw-r--r--src/searchterm.h5
-rw-r--r--tests/test_searchterm.cpp8
5 files changed, 27 insertions, 11 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 0697b20..8c4ebd7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -472,9 +472,11 @@ labelled Sent that shows the whole Maildir.
(`src/searchterm.h`), and that is what stops five surfaces growing five quoting
rules.** It holds no widget, so the grammar is tested without a painter or a web
engine. Two of its rules are load-bearing rather than cosmetic. `quote()`
-escapes backslashes BEFORE quotes, since the other order escapes the
-backslashes it just added; it truncates before escaping, so a cut cannot land
-mid-escape. And `extend()` parenthesises BOTH sides, because the query bar can
+DOUBLES an embedded quote, because notmuch has no backslash escape: a
+backslash-escaped quote ends the term, and a selection holding
+`x \" or tag:inbox or id:"` walked out of a `tag:nomatch and` scope and matched
+every message, measured, where the doubled form matched none. It truncates
+before doubling, so a cut cannot land mid-pair. And `extend()` parenthesises BOTH sides, because the query bar can
hold a hand-written disjunction and `a or b AND c` binds as `a or (b AND c)`,
which widens a search the user asked to narrow, reporting nothing. This is the
same trap the `post-new` hook handles when it scopes a rule with `tag:new`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6701417..a92ff4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,12 @@ point at which they are stable.
- The status bar renders its messages as plain text.
+### Fixed
+
+- Searching on selected text containing a double quote no longer lets the rest
+ of the selection escape the query. Quotes are now doubled, which is notmuch's
+ own escape; the backslash form ended the quoted term early.
+
## [0.30.0] - 2026-09-29
A calendar window over the vdirsyncer vdir, with a month grid, an agenda and
diff --git a/src/searchterm.cpp b/src/searchterm.cpp
index 6bfb521..16936df 100644
--- a/src/searchterm.cpp
+++ b/src/searchterm.cpp
@@ -34,10 +34,13 @@ QString quote(const QString &value)
if (cleaned.size() > kMaxValueLength)
cleaned.truncate(kMaxValueLength);
- // Backslashes first: escaping the quotes first would then escape the
- // backslashes this step adds, doubling them.
- cleaned.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
- cleaned.replace(QLatin1Char('"'), QStringLiteral("\\\""));
+ // notmuch escapes a quote inside a quoted term by DOUBLING it and has no
+ // backslash escape at all. A backslash-escaped quote ENDS the term, so
+ // `subject:"x \" or tag:inbox or id:"` let a selection walk out of the
+ // query it was meant to narrow: measured matching every message under a
+ // `tag:nomatch and` scope, where the doubled form matched none. A
+ // backslash is literal to notmuch and is left as it is.
+ cleaned.replace(QLatin1Char('"'), QStringLiteral("\"\""));
return QLatin1Char('"') + cleaned + QLatin1Char('"');
}
diff --git a/src/searchterm.h b/src/searchterm.h
index 3a0cac2..8ca67e4 100644
--- a/src/searchterm.h
+++ b/src/searchterm.h
@@ -59,8 +59,9 @@ enum class SearchMode {
/// Quotes an arbitrary value for use as a notmuch term.
///
/// Whitespace and newlines collapse to single spaces, embedded quotes are
-/// escaped, the value is capped at kMaxValueLength, and an empty or
-/// whitespace-only value yields an EMPTY STRING rather than `""`. Callers
+/// doubled (notmuch has no backslash escape), the value is capped at
+/// kMaxValueLength, and an empty or whitespace-only value yields an EMPTY
+/// STRING rather than `""`. Callers
/// test for empty to decide whether to offer a menu entry at all.
QString quote(const QString &value);
diff --git a/tests/test_searchterm.cpp b/tests/test_searchterm.cpp
index 53185f3..7a0dfd7 100644
--- a/tests/test_searchterm.cpp
+++ b/tests/test_searchterm.cpp
@@ -56,9 +56,13 @@ void TestSearchTerm::escapesEmbeddedQuotes()
{
// A selection is arbitrary prose and can carry a quote. Unescaped, it ends
// the quoted string early and the rest becomes stray query syntax, which
- // notmuch accepts and matches nothing on.
+ // notmuch accepts and matches nothing on. notmuch DOUBLES a quote; a
+ // backslash-escaped one ends the term and lets the rest escape the query.
QCOMPARE(SearchTerm::quote(QStringLiteral("say \"hello\" now")),
- QStringLiteral("\"say \\\"hello\\\" now\""));
+ QStringLiteral("\"say \"\"hello\"\" now\""));
+ // A backslash is literal to notmuch and must reach it unchanged.
+ QCOMPARE(SearchTerm::quote(QStringLiteral("x \\\" or tag:inbox")),
+ QStringLiteral("\"x \\\"\" or tag:inbox\""));
}
void TestSearchTerm::collapsesWhitespaceAndNewlines()