From 4f6d1ecb352ad8bba850ca5611ad4881d113bf52 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 20 Aug 2026 18:15:29 +0200 Subject: feat(compose): render markdown bodies with cmark-gfm, item 123 The composer's body is markdown and the text/html part is generated from it. cmark-gfm rather than plain cmark for autolink: under CommonMark a bare URL in a mail body is not a link, and in mail it is expected to be clickable. Three extensions are enabled and tables are deliberately not, since they render badly across mail clients whoever generates them. Raw HTML in the input is suppressed with CMARK_OPT_SAFE: the body is the user's own text, but a body that can inject markup into its own generated HTML part is a sharp edge with no upside. The build needs TWO lookups. Only the core library ships a pkg-config file; libcmark-gfm-extensions has none and is located with find_library, the way notmuch already is. All three extensions live in that second library, so finding only the first produces a build that compiles and silently renders plain CommonMark. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015muoUo2GdxmBDSp5vjYcbE --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) (limited to 'tests/CMakeLists.txt') diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d1d8a29..9f6e851 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,3 +74,4 @@ add_qtmaildir_test(translations) # only as English in a running Italian UI. target_compile_definitions(test_translations PRIVATE TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations") +add_qtmaildir_test(markdownrenderer) -- cgit v1.2.3 From d492192b7a9f682dac4a530a5a68ed74f006ddc2 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 20 Aug 2026 18:26:37 +0200 Subject: fix(compose): correct the header's attribution and harden three tests, item 123 The header still credited CMARK_OPT_SAFE after the .cpp comment and the test were corrected, which left the wrong mechanism named in the file MessageBuilder's author will actually read. Three test weaknesses, each measured rather than assumed. The accented-text test survived a SYMMETRIC latin-1 mutation, since the round trip cancels for codepoints under U+0100, so it now carries a character latin-1 cannot represent. The tasklist test asserted on the bare word "checked", which ordinary prose would satisfy, and now asserts the attribute. And the extension registration is wrapped in a function-local static: cmark-gfm's registry has no once-guard, and this project has a worker thread, so the first call racing itself would tear the registry rather than crash cleanly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015muoUo2GdxmBDSp5vjYcbE --- src/markdownrenderer.cpp | 15 ++++++++++----- src/markdownrenderer.h | 4 +++- tests/CMakeLists.txt | 2 +- tests/test_markdownrenderer.cpp | 11 +++++++++-- 4 files changed, 23 insertions(+), 9 deletions(-) (limited to 'tests/CMakeLists.txt') diff --git a/src/markdownrenderer.cpp b/src/markdownrenderer.cpp index ccb3309..7158981 100644 --- a/src/markdownrenderer.cpp +++ b/src/markdownrenderer.cpp @@ -45,11 +45,16 @@ QString MarkdownRenderer::toHtml(const QString &markdown) if (markdown.isEmpty()) return {}; - // Idempotent and required before cmark_find_syntax_extension() can resolve - // any name. Calling it per render rather than once at startup keeps this - // function free of initialisation order concerns; it is a hash lookup - // after the first call. - cmark_gfm_core_extensions_ensure_registered(); + // Idempotent, and a hash lookup after the first call. The function-local + // static makes the FIRST call thread-safe: cmark-gfm's registry carries no + // once-guard of its own, so two threads racing the first call would tear + // it. Today's only caller is on the UI thread; this costs nothing and + // removes the trap before a worker-thread caller finds it. + static const bool registered = [] { + cmark_gfm_core_extensions_ensure_registered(); + return true; + }(); + Q_UNUSED(registered) // CMARK_OPT_DEFAULT is 0, and CMARK_OPT_SAFE is a NO-OP in cmark-gfm 0.29: // safe mode has been the default since that release, and the flag is kept diff --git a/src/markdownrenderer.h b/src/markdownrenderer.h index 80c52bf..6373fd9 100644 --- a/src/markdownrenderer.h +++ b/src/markdownrenderer.h @@ -32,7 +32,9 @@ namespace MarkdownRenderer { /// /// Three extensions are enabled (autolink, strikethrough, tasklist) and /// tables are deliberately not. Raw HTML in the input is suppressed by -/// CMARK_OPT_SAFE. +/// cmark-gfm's safe mode, which is the DEFAULT in 0.29 and is not the +/// CMARK_OPT_SAFE flag (a no-op); see markdownrenderer.cpp for the +/// measurement. The requirement is that CMARK_OPT_UNSAFE is never set. QString toHtml(const QString &markdown); } // namespace MarkdownRenderer diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9f6e851..58d5659 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -68,10 +68,10 @@ add_qtmaildir_test(searchterm) add_qtmaildir_test(busyindicator) add_qtmaildir_test(tagstrip) add_qtmaildir_test(messagedetailsdialog) +add_qtmaildir_test(markdownrenderer) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up # only as English in a running Italian UI. target_compile_definitions(test_translations PRIVATE TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations") -add_qtmaildir_test(markdownrenderer) diff --git a/tests/test_markdownrenderer.cpp b/tests/test_markdownrenderer.cpp index 0c35b6c..697a28f 100644 --- a/tests/test_markdownrenderer.cpp +++ b/tests/test_markdownrenderer.cpp @@ -74,7 +74,10 @@ void TestMarkdownRenderer::tasklistRenders() const QString html = MarkdownRenderer::toHtml( QStringLiteral("- [ ] todo\n- [x] done")); QVERIFY2(html.contains(QStringLiteral("type=\"checkbox\"")), qPrintable(html)); - QVERIFY2(html.contains(QStringLiteral("checked")), qPrintable(html)); + // Not a bare "checked": that is a common English word ordinary prose + // would satisfy on its own. The attribute is what proves [x] differs + // from [ ]. + QVERIFY2(html.contains(QStringLiteral("checked=\"\"")), qPrintable(html)); } void TestMarkdownRenderer::tablesAreNotEnabled() @@ -127,7 +130,11 @@ void TestMarkdownRenderer::accentedTextSurvivesAsUtf8() // This user writes Italian, so accented text is every message rather // than an edge case, and a UTF-8 round trip through a C library is // exactly where it would be lost. - const QString source = QString::fromUtf8("perch\xC3\xA9 \xC3\xA8 cos\xC3\xAC"); + // + // Includes a character outside latin-1, so a symmetric toLatin1/fromLatin1 + // substitution cannot round-trip it and cancel itself out. Measured: with + // accented latin-1 text alone, mutating both sides together passes. + const QString source = QString::fromUtf8("perch\xC3\xA9 \xC3\xA8 cos\xC3\xAC \xE2\x82\xAC"); const QString html = MarkdownRenderer::toHtml(source); QVERIFY2(html.contains(source), qPrintable(html)); } -- cgit v1.2.3 From ccf6f436c00d95c2caa696d583ec23d667ff3e60 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 20 Aug 2026 18:34:15 +0200 Subject: refactor(maildir): extract freshMaildirName for reuse, item 123 DraftStore needs the same filename generation moveMessages() already has, and duplicating it would duplicate a correctness property rather than a convenience: the comment records that carrying mbsync's ,U= infix across a folder boundary produced 'Maildir error: duplicate UID' on real mail. A pure move with no behaviour change, committed on its own so a bisect can tell it apart from the feature that needed it. The function gains its own tests, including the UID-infix case that previously had none. --- src/CMakeLists.txt | 1 + src/maildirname.cpp | 80 +++++++++++++++++++++++++++++++++++++++ src/maildirname.h | 41 ++++++++++++++++++++ src/notmuchworker.cpp | 64 ++----------------------------- tests/CMakeLists.txt | 1 + tests/test_maildirname.cpp | 93 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 219 insertions(+), 61 deletions(-) create mode 100644 src/maildirname.cpp create mode 100644 src/maildirname.h create mode 100644 tests/test_maildirname.cpp (limited to 'tests/CMakeLists.txt') diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6108696..12168a5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,6 +11,7 @@ add_library(qtmaildir_lib STATIC marks.cpp carddelegate.cpp notmuchworker.cpp + maildirname.cpp tagchip.cpp tagcolors.cpp savequerydialog.cpp diff --git a/src/maildirname.cpp b/src/maildirname.cpp new file mode 100644 index 0000000..6263aec --- /dev/null +++ b/src/maildirname.cpp @@ -0,0 +1,80 @@ +/* + * 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 "maildirname.h" + +#include +#include +#include + +namespace MaildirName { + +/// A fresh Maildir filename for a message being moved between folders, +/// preserving only its `:2,` suffix. +/// +/// mbsync's manual is explicit about why this exists, under "the more +/// efficient default UID mapping scheme": "it is important that the MUA +/// renames files when moving them between Maildir folders", and "the general +/// expectation is that a completely new filename is generated as if the +/// message was new". +/// +/// The `,U=` infix mbsync writes is its per-folder IMAP UID. Carrying it +/// into another folder makes it a claim about a folder the file is no longer +/// in; moving a message out and back then reinserts a UID the server has +/// since reassigned, and mbsync refuses the folder with `Maildir error: +/// duplicate UID`. Measured on real mail, four collisions in one folder from +/// a single move-and-restore. +/// +/// The FLAGS are kept, deliberately, and that is not a contradiction of +/// "as if the message was new". They record seen, flagged and replied, and +/// `maildir.synchronize_flags` is true, so notmuch reads them back as tags: +/// dropping them would mark every deleted message unread and lose Important +/// on the way to the trash. Only the unique part is regenerated. +QString fresh(const QString &oldName) +{ + // The `:2,` suffix, when there is one. `info` is everything from the + // separator on, so an empty-flag `:2,` is preserved as faithfully as + // `:2,FS`. + QString info; + const int sep = oldName.indexOf(QStringLiteral(":2,")); + if (sep >= 0) + info = oldName.mid(sep); + + // The conventional left-to-right unique part: time, a per-process counter, + // the pid, the host. The counter is what makes two messages moved in the + // same second distinct, which a timestamp alone does not guarantee. + static quint64 counter = 0; + const qint64 now = QDateTime::currentSecsSinceEpoch(); + const QString host = QHostInfo::localHostName().isEmpty() + ? QStringLiteral("localhost") + : QHostInfo::localHostName(); + + return QStringLiteral("%1.M%2P%3Q%4.%5%6") + .arg(now) + .arg(QDateTime::currentMSecsSinceEpoch() % 1000) + .arg(QCoreApplication::applicationPid()) + .arg(++counter) + // A `/` or a `:` in a hostname would break the path or the flag + // separator. Neither is legal in a hostname, so this is belt and + // braces rather than a known case. + .arg(QString(host).replace(QLatin1Char('/'), QLatin1Char('_')) + .replace(QLatin1Char(':'), QLatin1Char('_'))) + .arg(info); +} + +} // namespace MaildirName diff --git a/src/maildirname.h b/src/maildirname.h new file mode 100644 index 0000000..f24bc71 --- /dev/null +++ b/src/maildirname.h @@ -0,0 +1,41 @@ +/* + * 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. + */ + +#pragma once + +#include + +/// Maildir filename generation, shared by every path that writes a message +/// file: NotmuchWorker::moveMessages() and DraftStore. +/// +/// A namespace rather than a class; there is no state beyond a counter. +namespace MaildirName { + +/// A fresh, unique Maildir filename, preserving \p oldName's flag suffix. +/// +/// A FRESH name, never a reuse. mbsync writes a `,U=` infix that is +/// meaningful only within one folder, and carrying it across a folder +/// boundary produced "Maildir error: duplicate UID" on real mail. Only the +/// `:2,` flag suffix is carried, because the flags describe the message +/// rather than its position. +/// +/// Pass an empty string for a message that has no previous name, which is +/// what a newly composed draft is. +QString fresh(const QString &oldName); + +} // namespace MaildirName diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index d0274cd..8c28ec5 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -20,16 +20,15 @@ #include -#include #include #include #include #include -#include #include #include +#include "maildirname.h" #include "mimeparser.h" #include "nmraii.h" @@ -687,63 +686,6 @@ void NotmuchWorker::applyTags(const TagChange &change) emit tagsApplied(change); } -namespace { - -/// A fresh Maildir filename for a message being moved between folders, -/// preserving only its `:2,` suffix. -/// -/// mbsync's manual is explicit about why this exists, under "the more -/// efficient default UID mapping scheme": "it is important that the MUA -/// renames files when moving them between Maildir folders", and "the general -/// expectation is that a completely new filename is generated as if the -/// message was new". -/// -/// The `,U=` infix mbsync writes is its per-folder IMAP UID. Carrying it -/// into another folder makes it a claim about a folder the file is no longer -/// in; moving a message out and back then reinserts a UID the server has -/// since reassigned, and mbsync refuses the folder with `Maildir error: -/// duplicate UID`. Measured on real mail, four collisions in one folder from -/// a single move-and-restore. -/// -/// The FLAGS are kept, deliberately, and that is not a contradiction of -/// "as if the message was new". They record seen, flagged and replied, and -/// `maildir.synchronize_flags` is true, so notmuch reads them back as tags: -/// dropping them would mark every deleted message unread and lose Important -/// on the way to the trash. Only the unique part is regenerated. -QString freshMaildirName(const QString &oldName) -{ - // The `:2,` suffix, when there is one. `info` is everything from the - // separator on, so an empty-flag `:2,` is preserved as faithfully as - // `:2,FS`. - QString info; - const int sep = oldName.indexOf(QStringLiteral(":2,")); - if (sep >= 0) - info = oldName.mid(sep); - - // The conventional left-to-right unique part: time, a per-process counter, - // the pid, the host. The counter is what makes two messages moved in the - // same second distinct, which a timestamp alone does not guarantee. - static quint64 counter = 0; - const qint64 now = QDateTime::currentSecsSinceEpoch(); - const QString host = QHostInfo::localHostName().isEmpty() - ? QStringLiteral("localhost") - : QHostInfo::localHostName(); - - return QStringLiteral("%1.M%2P%3Q%4.%5%6") - .arg(now) - .arg(QDateTime::currentMSecsSinceEpoch() % 1000) - .arg(QCoreApplication::applicationPid()) - .arg(++counter) - // A `/` or a `:` in a hostname would break the path or the flag - // separator. Neither is legal in a hostname, so this is belt and - // braces rather than a known case. - .arg(QString(host).replace(QLatin1Char('/'), QLatin1Char('_')) - .replace(QLatin1Char(':'), QLatin1Char('_'))) - .arg(info); -} - -} // namespace - void NotmuchWorker::moveMessages(const QStringList &messageIds, const QString &destFolder) { @@ -822,11 +764,11 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds, continue; } - // A FRESH name, never the old one. See freshMaildirName(): carrying + // A FRESH name, never the old one. See MaildirName::fresh(): carrying // the `,U=` infix across a folder boundary is what produced // `Maildir error: duplicate UID` on real mail. const QString to = destDir + QLatin1Char('/') - + freshMaildirName(QFileInfo(from).fileName()); + + MaildirName::fresh(QFileInfo(from).fileName()); if (!QFile::rename(from, to)) { emit errorOccurred(QStringLiteral("Cannot move %1 to %2") diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 58d5659..15f9955 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -69,6 +69,7 @@ add_qtmaildir_test(busyindicator) add_qtmaildir_test(tagstrip) add_qtmaildir_test(messagedetailsdialog) add_qtmaildir_test(markdownrenderer) +add_qtmaildir_test(maildirname) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up diff --git a/tests/test_maildirname.cpp b/tests/test_maildirname.cpp new file mode 100644 index 0000000..dcc8fab --- /dev/null +++ b/tests/test_maildirname.cpp @@ -0,0 +1,93 @@ +/* + * 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 "maildirname.h" + +#include +#include + +class TestMaildirName : public QObject +{ + Q_OBJECT + +private slots: + void aFreshNameIsUniquePerCall(); + void theFlagSuffixIsPreserved(); + void anEmptyFlagSuffixIsPreserved(); + void aNameWithNoSuffixGetsNone(); + void theUidInfixIsNotCarriedAcross(); +}; + +// Two messages written in the same second must not collide, which a +// timestamp alone does not guarantee, and that is what the counter is for. +void TestMaildirName::aFreshNameIsUniquePerCall() +{ + QSet names; + for (int i = 0; i < 100; ++i) + names.insert(MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host"))); + + QVERIFY2(names.size() == 100, + qPrintable(QStringLiteral("expected 100 unique names, got %1") + .arg(names.size()))); +} + +// The flags say whether a message is read, flagged or draft, and losing them +// on a move silently marks mail unread again. +void TestMaildirName::theFlagSuffixIsPreserved() +{ + const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host:2,FS")); + QVERIFY2(name.endsWith(QStringLiteral(":2,FS")), + qPrintable(QStringLiteral("generated name did not preserve flags: %1") + .arg(name))); +} + +// `:2,` with no flags is not the same as no suffix at all, it says the flags +// are known and empty. +void TestMaildirName::anEmptyFlagSuffixIsPreserved() +{ + const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host:2,")); + QVERIFY2(name.endsWith(QStringLiteral(":2,")), + qPrintable(QStringLiteral("generated name did not preserve empty flag suffix: %1") + .arg(name))); +} + +// A suffix must not be invented. +void TestMaildirName::aNameWithNoSuffixGetsNone() +{ + const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host")); + QVERIFY2(!name.contains(QStringLiteral(":2,")), + qPrintable(QStringLiteral("generated name invented a flag suffix: %1") + .arg(name))); +} + +// This is the reason the function exists; carrying mbsync's `,U=` infix +// across a folder boundary produced "Maildir error: duplicate UID" on real +// mail. +void TestMaildirName::theUidInfixIsNotCarriedAcross() +{ + const QString name = MaildirName::fresh(QStringLiteral("1234.M1P1Q1.host,U=42:2,S")); + QVERIFY2(!name.contains(QStringLiteral("U=42")), + qPrintable(QStringLiteral("generated name carried the UID infix across: %1") + .arg(name))); + QVERIFY2(name.endsWith(QStringLiteral(":2,S")), + qPrintable(QStringLiteral("generated name did not preserve flags: %1") + .arg(name))); +} + +QTEST_MAIN(TestMaildirName) +#include "test_maildirname.moc" -- cgit v1.2.3 From 14e74f8f92ce949230e3008de8fd24d0c5a18153 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 20 Aug 2026 18:41:21 +0200 Subject: feat(compose): build outgoing messages with GMime, item 123 One built message serves three consumers: the autosaved draft, the bytes on the send command's stdin, and the sent copy. A draft is therefore byte-identical to what would be sent. Three GMime defaults are wrong for this application and each is corrected explicitly, because all three fail only on accented text and this user writes Italian: GMime encodes as iso-8859-1 unless told otherwise, so the subject carries an explicit utf-8 argument. g_mime_text_part_set_text() encodes with whatever charset is set when it is CALLED, so setting the charset afterwards produces a part labelled utf-8 carrying latin-1 bytes; the content stream is built directly instead. And neither Date nor Message-ID is generated unless asked for, and a message without a Message-ID cannot be threaded by anything that receives it. Attachments are checked at build time rather than at attach time: a file can vanish in between, and a message missing the thing it was written to carry must never reach the send command. An account with no address fails the build rather than producing a message with an empty From. Config::account() returns a default-constructed Account for an unknown key rather than failing, so without that guard a bad key would produce silently malformed mail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015muoUo2GdxmBDSp5vjYcbE --- src/CMakeLists.txt | 1 + src/messagebuilder.cpp | 286 +++++++++++++++++++++++++++++++++++ src/messagebuilder.h | 59 ++++++++ tests/CMakeLists.txt | 1 + tests/test_messagebuilder.cpp | 322 ++++++++++++++++++++++++++++++++++++++++ translations/qtmaildir_it_IT.ts | 16 ++ 6 files changed, 685 insertions(+) create mode 100644 src/messagebuilder.cpp create mode 100644 src/messagebuilder.h create mode 100644 tests/test_messagebuilder.cpp (limited to 'tests/CMakeLists.txt') diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 12168a5..b7a5be2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(qtmaildir_lib STATIC config.cpp mimeparser.cpp markdownrenderer.cpp + messagebuilder.cpp requestinterceptor.cpp htmlbuilder.cpp cidschemehandler.cpp diff --git a/src/messagebuilder.cpp b/src/messagebuilder.cpp new file mode 100644 index 0000000..04982c9 --- /dev/null +++ b/src/messagebuilder.cpp @@ -0,0 +1,286 @@ +/* + * 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. + */ + +// gmime.h pulls in glib's gio headers, which declare a struct field named +// "signals". Qt's #defines "signals" to "Q_SIGNALS" +// (unless QT_NO_KEYWORDS is set), so gmime.h must be included before any Qt +// header in this translation unit to avoid a macro collision. +#include + +#include "messagebuilder.h" + +#include +#include +#include +#include +#include + +#include "config.h" +#include "markdownrenderer.h" + +namespace { + +/// GMime must be initialised exactly once per process. MimeParser has its own +/// copy of this guard; both are cheap and neither can assume the other ran, +/// since a test may link only one of them. +void ensureGMimeInitialised() +{ + static bool initialised = false; + if (!initialised) { + g_mime_init(); + initialised = true; + } +} + +/// A text part carrying \p text as utf-8, quoted-printable. +/// +/// Deliberately NOT g_mime_text_part_set_text(). Measured 2026-08-20: that +/// function encodes using the charset set at the moment it is CALLED, so the +/// obvious "set the text, then set the charset" order relabels the part without +/// re-encoding it. The result is a part headed charset=utf-8 whose bytes are +/// latin-1 (`Perch=E9`), which looks correct in every header and arrives as +/// mojibake. Building the content stream from the utf-8 bytes directly was +/// measured to produce `Perch=C3=A9` correctly. This user writes Italian, so an +/// accented character is in every message, not an edge case. +GMimePart *makeTextPart(const char *subtype, const QString &text) +{ + GMimePart *part = g_mime_part_new_with_type("text", subtype); + g_mime_object_set_content_type_parameter(GMIME_OBJECT(part), "charset", "utf-8"); + + const QByteArray utf8 = text.toUtf8(); + GMimeStream *stream = g_mime_stream_mem_new_with_buffer(utf8.constData(), + static_cast(utf8.size())); + GMimeDataWrapper *wrapper = + g_mime_data_wrapper_new_with_stream(stream, GMIME_CONTENT_ENCODING_DEFAULT); + g_mime_part_set_content(part, wrapper); + g_mime_part_set_content_encoding(part, GMIME_CONTENT_ENCODING_QUOTEDPRINTABLE); + + g_object_unref(wrapper); + g_object_unref(stream); + return part; +} + +/// Sets \p header on \p message to \p addresses, RFC 2047 encoded as utf-8. +/// +/// Each entry is passed through internet_address_list_parse() rather than +/// treated as a bare address, because the composer's fields hold whatever the +/// user typed and "Name " is the ordinary form. Parsing per +/// entry rather than joining first keeps a comma inside a quoted display name +/// from splitting one recipient into two. +void setAddressHeader(GMimeMessage *message, const char *header, const QStringList &addresses) +{ + if (addresses.isEmpty()) + return; + + InternetAddressList *list = internet_address_list_new(); + for (const QString &entry : addresses) { + const QString trimmed = entry.trimmed(); + if (trimmed.isEmpty()) + continue; + const QByteArray utf8 = trimmed.toUtf8(); + InternetAddressList *parsed = internet_address_list_parse(nullptr, utf8.constData()); + if (!parsed) + continue; + internet_address_list_append(list, parsed); + g_object_unref(parsed); + } + + if (internet_address_list_length(list) > 0) { + GMimeFormatOptions *format = g_mime_format_options_get_default(); + char *rendered = internet_address_list_to_string(list, format, TRUE); + if (rendered) { + g_mime_object_set_header(GMIME_OBJECT(message), header, rendered, "utf-8"); + g_free(rendered); + } + } + g_object_unref(list); +} + +} // namespace + +namespace MessageBuilder { + +Result build(const OutgoingMessage &message, const Account &account) +{ + Result result; + + // Config::account() returns a DEFAULT-CONSTRUCTED Account for an unknown + // key rather than reporting an error, so an account reached by a stale or + // mistyped key arrives here looking like a valid one with empty fields. + // Building from it would produce a message with an empty From: silently + // malformed mail handed to the send command as though it were fine. + if (account.address.trimmed().isEmpty()) { + result.error = QObject::tr("The account has no address configured, so no message " + "can be sent from it."); + return result; + } + + // Attachments are checked HERE rather than when the file was attached: a + // file can vanish in between, and a message missing the thing it was + // written to carry must never reach the send command. Checked before + // anything is allocated, so the failure path frees nothing. + for (const QString &path : message.attachments) { + const QFileInfo info(path); + if (!info.exists() || !info.isReadable()) { + result.error = QObject::tr("The attachment %1 is missing or unreadable.") + .arg(info.fileName().isEmpty() ? path : info.fileName()); + return result; + } + } + + ensureGMimeInitialised(); + + GMimeMessage *mime = g_mime_message_new(TRUE); + + const QByteArray fromName = account.name.toUtf8(); + const QByteArray fromAddress = account.address.toUtf8(); + g_mime_message_add_mailbox(mime, GMIME_ADDRESS_TYPE_FROM, + account.name.isEmpty() ? nullptr : fromName.constData(), + fromAddress.constData()); + + setAddressHeader(mime, "To", message.to); + setAddressHeader(mime, "Cc", message.cc); + // Bcc is written into the bytes deliberately. The documented send command + // is `msmtp -t`, which reads its recipients FROM the headers and strips Bcc + // itself before transmission; omitting it here would mean blind recipients + // never receive the message at all, silently. If sending ever passes + // recipients as arguments instead, this line must go with it. + setAddressHeader(mime, "Bcc", message.bcc); + + // The explicit "utf-8". Measured 2026-08-20: with NULL here GMime encodes + // the subject as iso-8859-1 (=?iso-8859-1?B?...?=). + const QByteArray subject = message.subject.toUtf8(); + g_mime_message_set_subject(mime, subject.constData(), "utf-8"); + + if (!message.inReplyTo.trimmed().isEmpty()) { + const QByteArray value = message.inReplyTo.trimmed().toUtf8(); + g_mime_object_set_header(GMIME_OBJECT(mime), "In-Reply-To", value.constData(), "utf-8"); + } + if (!message.references.isEmpty()) { + const QByteArray value = message.references.join(QLatin1Char(' ')).toUtf8(); + g_mime_object_set_header(GMIME_OBJECT(mime), "References", value.constData(), "utf-8"); + } + + // Measured 2026-08-20: GMime generates neither Date nor Message-ID unless + // asked. A message without a Message-ID cannot be threaded by anything that + // receives it, this application's own index of the sent copy included. + GDateTime *now = g_date_time_new_now_local(); + g_mime_message_set_date(mime, now); + g_date_time_unref(now); + + const QString domain = account.address.section(QLatin1Char('@'), 1); + const QByteArray domainUtf8 = (domain.isEmpty() ? QStringLiteral("localhost") : domain).toUtf8(); + char *generatedId = g_mime_utils_generate_message_id(domainUtf8.constData()); + if (generatedId) { + g_mime_message_set_message_id(mime, generatedId); + result.messageId = QString::fromUtf8(generatedId); + g_free(generatedId); + } + + // The markdown SOURCE is the plain part, never a stripped-of-syntax + // rewrite: `**bold**` reads as emphasis, and rewriting it would mean a + // second renderer whose output could disagree with the HTML one. + GMimeObject *body = GMIME_OBJECT(makeTextPart("plain", message.markdownBody)); + + if (message.sendHtml) { + GMimePart *html = makeTextPart("html", MarkdownRenderer::toHtml(message.markdownBody)); + GMimeMultipart *alternative = g_mime_multipart_new_with_subtype("alternative"); + // Least-rich FIRST. A client renders the LAST alternative it + // understands, so a reversed order shows the markdown source everywhere + // and the rendered part is never seen. + g_mime_multipart_add(alternative, body); + g_mime_multipart_add(alternative, GMIME_OBJECT(html)); + g_object_unref(body); + g_object_unref(html); + body = GMIME_OBJECT(alternative); + } + + if (!message.attachments.isEmpty()) { + GMimeMultipart *mixed = g_mime_multipart_new_with_subtype("mixed"); + // The body goes in FIRST, so the wrapper NESTS it rather than standing + // beside it. Beside it, a client shows the alternatives as attachments + // and the message reads as empty. + g_mime_multipart_add(mixed, body); + g_object_unref(body); + + QMimeDatabase mimeDb; + for (const QString &path : message.attachments) { + const QFileInfo info(path); + const QMimeType type = mimeDb.mimeTypeForFile(info); + const QByteArray typeName = type.name().toUtf8(); + + GMimeContentType *contentType = + g_mime_content_type_parse(nullptr, typeName.isEmpty() + ? "application/octet-stream" + : typeName.constData()); + GMimePart *part = g_mime_part_new(); + if (contentType) { + g_mime_object_set_content_type(GMIME_OBJECT(part), contentType); + g_object_unref(contentType); + } + + GMimeStream *stream = g_mime_stream_file_open(path.toLocal8Bit().constData(), + "r", nullptr); + if (!stream) { + // Existence was checked above, so reaching here means the file + // went away between the check and the read. Fail rather than + // send a message with a hole in it. + g_object_unref(part); + g_object_unref(mixed); + g_object_unref(mime); + result.bytes.clear(); + result.messageId.clear(); + result.error = QObject::tr("The attachment %1 could not be read.") + .arg(info.fileName()); + return result; + } + GMimeDataWrapper *wrapper = + g_mime_data_wrapper_new_with_stream(stream, GMIME_CONTENT_ENCODING_DEFAULT); + g_mime_part_set_content(part, wrapper); + g_mime_part_set_content_encoding(part, GMIME_CONTENT_ENCODING_BASE64); + g_object_unref(wrapper); + g_object_unref(stream); + + const QByteArray filename = info.fileName().toUtf8(); + g_mime_part_set_filename(part, filename.constData()); + g_mime_object_set_disposition(GMIME_OBJECT(part), "attachment"); + + g_mime_multipart_add(mixed, GMIME_OBJECT(part)); + g_object_unref(part); + } + body = GMIME_OBJECT(mixed); + } + + g_mime_message_set_mime_part(mime, body); + g_object_unref(body); + + GMimeFormatOptions *format = g_mime_format_options_get_default(); + char *rendered = g_mime_object_to_string(GMIME_OBJECT(mime), format); + if (rendered) { + result.bytes = QByteArray(rendered); + g_free(rendered); + } else { + result.error = QObject::tr("The message could not be assembled."); + result.messageId.clear(); + } + + g_object_unref(mime); + return result; +} + +} // namespace MessageBuilder diff --git a/src/messagebuilder.h b/src/messagebuilder.h new file mode 100644 index 0000000..f9de277 --- /dev/null +++ b/src/messagebuilder.h @@ -0,0 +1,59 @@ +/* + * 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. + */ + +#pragma once + +#include +#include + +#include "types.h" + +struct Account; + +/// Turns an OutgoingMessage into the RFC822 bytes that get sent. +/// +/// ONE built message serves three consumers: the autosaved draft, the bytes on +/// the send command's stdin, and the sent copy. A draft is therefore +/// byte-identical to what would be sent. +/// +/// GMime rather than assembling RFC822 by string. The alternative means +/// reimplementing RFC 2047 header encoding, quoted-printable for accented +/// bodies, boundary uniqueness and line-length limits. This user writes +/// Italian; a body containing an accented character is every message, and a +/// bug there produces mail that looks correct locally and arrives as mojibake. +namespace MessageBuilder { + +struct Result +{ + QByteArray bytes; ///< The complete message. Empty on failure. + QString error; ///< Empty on success. + QString messageId; ///< The generated Message-ID, for the caller's records. + + bool ok() const { return error.isEmpty(); } +}; + +/// Builds \p message as sent from \p account. +/// +/// Fails, rather than sending a partial message, when an attachment named in +/// the message no longer exists. That is checked HERE, at build time, rather +/// than when the file was attached: a file can vanish in between, and the +/// failure must stop the send rather than produce a message missing the thing +/// it was written to carry. +Result build(const OutgoingMessage &message, const Account &account); + +} // namespace MessageBuilder diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 15f9955..8c6231d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -69,6 +69,7 @@ add_qtmaildir_test(busyindicator) add_qtmaildir_test(tagstrip) add_qtmaildir_test(messagedetailsdialog) add_qtmaildir_test(markdownrenderer) +add_qtmaildir_test(messagebuilder) add_qtmaildir_test(maildirname) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated diff --git a/tests/test_messagebuilder.cpp b/tests/test_messagebuilder.cpp new file mode 100644 index 0000000..289006c --- /dev/null +++ b/tests/test_messagebuilder.cpp @@ -0,0 +1,322 @@ +/* + * 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 +#include +#include +#include +#include + +#include "config.h" +#include "messagebuilder.h" +#include "types.h" + +/// MessageBuilder's tests assert on the GENERATED BYTES, never by round-tripping +/// through MimeParser. A builder and a parser that agree can be wrong together: +/// both are ours, and a shared misunderstanding of a charset or a part order +/// would show as a green suite and as mojibake on the recipient's screen. +class TestMessageBuilder : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + void plainOnlyWhenSendHtmlIsOff(); + void multipartAlternativeWhenSendHtmlIsOn(); + void thePlainPartCarriesTheMarkdownSourceUnmodified(); + void theHtmlPartIsRenderedFromTheSameSource(); + void anAccentedBodyIsUtf8QuotedPrintable(); + void anAccentedSubjectIsRfc2047Utf8(); + void inReplyToAndReferencesAreCarried(); + void attachmentsProduceMultipartMixed(); + void aMissingAttachmentFailsTheBuild(); + void everyMessageCarriesADateAndMessageId(); + void recipientsAppearInTheirOwnHeaders(); + void anAccountWithNoAddressFailsRatherThanBuildingHeaderlessMail(); + +private: + Account m_account; + + /// A message with the fixture account and one recipient, so each test can + /// change only the field it is about. + OutgoingMessage baseMessage() const + { + OutgoingMessage m; + m.accountKey = m_account.key; + m.to = QStringList{QStringLiteral("someone@example.org")}; + m.subject = QStringLiteral("A subject"); + m.markdownBody = QStringLiteral("Hello there."); + return m; + } +}; + +void TestMessageBuilder::initTestCase() +{ + m_account.key = QStringLiteral("work"); + m_account.name = QStringLiteral("Danilo M."); + m_account.address = QStringLiteral("user@example.org"); + m_account.maildir = QStringLiteral("work"); + m_account.sendCommand = QStringLiteral("/bin/true"); +} + +/// With the HTML toggle off the message must be a single text/plain part. +/// A multipart/alternative carrying one alternative is not merely wasteful: it +/// makes every message an attachment-bearing shape to some clients, and the +/// toggle exists precisely so a user can send mail nothing has to negotiate. +void TestMessageBuilder::plainOnlyWhenSendHtmlIsOff() +{ + OutgoingMessage m = baseMessage(); + m.sendHtml = false; + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY(text.contains(QStringLiteral("Content-Type: text/plain"))); + QVERIFY(!text.contains(QStringLiteral("multipart/alternative"))); + QVERIFY(!text.contains(QStringLiteral("text/html"))); +} + +/// With the toggle on both parts must be present, and text/plain must come +/// FIRST. Order is load-bearing in multipart/alternative: a client renders the +/// LAST part it understands, so least-rich first. Reversed, every HTML-capable +/// client would show the markdown source and the rendered part would never be +/// seen by anyone. +void TestMessageBuilder::multipartAlternativeWhenSendHtmlIsOn() +{ + OutgoingMessage m = baseMessage(); + m.sendHtml = true; + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY(text.contains(QStringLiteral("multipart/alternative"))); + + const int plain = text.indexOf(QStringLiteral("text/plain")); + const int html = text.indexOf(QStringLiteral("text/html")); + QVERIFY(plain >= 0); + QVERIFY(html >= 0); + QVERIFY2(plain < html, "text/plain must precede text/html in multipart/alternative"); +} + +/// The markdown SOURCE is the plain part, not a stripped-of-syntax rendering of +/// it. `**bold**` reads as emphasis to a human, and a plain-text renderer would +/// mean inventing a second renderer whose output could disagree with the HTML +/// one. The draft the user autosaves is this same text, which is the other +/// reason it must not be rewritten on the way out. +void TestMessageBuilder::thePlainPartCarriesTheMarkdownSourceUnmodified() +{ + OutgoingMessage m = baseMessage(); + m.sendHtml = true; + m.markdownBody = QStringLiteral("**bold** and - [ ] a task"); + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY2(text.contains(QStringLiteral("**bold** and - [ ] a task")), + qPrintable(text)); +} + +/// The HTML part comes from the same source through MarkdownRenderer, so the +/// two parts can never describe different messages. +void TestMessageBuilder::theHtmlPartIsRenderedFromTheSameSource() +{ + OutgoingMessage m = baseMessage(); + m.sendHtml = true; + m.markdownBody = QStringLiteral("**bold**"); + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY2(text.contains(QStringLiteral("bold")), qPrintable(text)); +} + +/// Measured 2026-08-20: g_mime_text_part_set_text() encodes with whatever +/// charset is set at the moment it is CALLED, so setting the charset afterwards +/// RELABELS the part without re-encoding it. That produces a part headed +/// charset=utf-8 whose bytes are latin-1 (`Perch=E9`), which looks correct in +/// every header and arrives as mojibake. Asserting on the label alone would +/// pass against exactly that bug, so this asserts on the BYTES too: =C3=A9 must +/// be there and =E9 must not. +void TestMessageBuilder::anAccentedBodyIsUtf8QuotedPrintable() +{ + OutgoingMessage m = baseMessage(); + m.markdownBody = QStringLiteral("perché è così"); + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY2(text.contains(QStringLiteral("charset=utf-8"), Qt::CaseInsensitive), + qPrintable(text)); + QVERIFY2(text.contains(QStringLiteral("=C3=A9")), qPrintable(text)); + QVERIFY2(!text.contains(QStringLiteral("=E9\n")) && !text.contains(QStringLiteral("=E9 ")), + "latin-1 bytes under a utf-8 label"); +} + +/// Measured 2026-08-20: GMime encodes a header as iso-8859-1 unless told +/// otherwise, so g_mime_message_set_subject(msg, text, NULL) produced +/// =?iso-8859-1?B?...?=. The explicit "utf-8" argument is what makes an Italian +/// subject survive. +void TestMessageBuilder::anAccentedSubjectIsRfc2047Utf8() +{ + OutgoingMessage m = baseMessage(); + m.subject = QStringLiteral("Perché no"); + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY2(text.contains(QStringLiteral("=?UTF-8?"), Qt::CaseInsensitive), qPrintable(text)); + QVERIFY2(!text.contains(QStringLiteral("=?iso-8859-1?"), Qt::CaseInsensitive), + qPrintable(text)); +} + +/// Not optional decoration. Without In-Reply-To and References a reply appears +/// as an orphan thread in the sender's own client, since the sent copy is +/// indexed by notmuch like any other message and notmuch threads on these +/// headers. +void TestMessageBuilder::inReplyToAndReferencesAreCarried() +{ + OutgoingMessage m = baseMessage(); + m.inReplyTo = QStringLiteral(""); + m.references = QStringList{QStringLiteral(""), + QStringLiteral("")}; + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY2(text.contains(QStringLiteral("In-Reply-To: ")), qPrintable(text)); + QVERIFY2(text.contains(QStringLiteral("References:")), qPrintable(text)); + QVERIFY2(text.contains(QStringLiteral("")), qPrintable(text)); +} + +/// The attachment wrapper must NEST the body, not sit beside it: multipart/mixed +/// outermost, with the multipart/alternative as its first part. Beside it, a +/// client would show the alternatives as attachments and the body would be +/// unreadable. Position in the byte stream is what distinguishes the two, so the +/// test asserts mixed appears BEFORE alternative. +void TestMessageBuilder::attachmentsProduceMultipartMixed() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("notes.txt")); + QFile f(path); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("some attached bytes\n"); + f.close(); + + OutgoingMessage m = baseMessage(); + m.sendHtml = true; + m.attachments = QStringList{path}; + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + const int mixed = text.indexOf(QStringLiteral("multipart/mixed")); + const int alternative = text.indexOf(QStringLiteral("multipart/alternative")); + QVERIFY2(mixed >= 0, qPrintable(text)); + QVERIFY2(alternative >= 0, qPrintable(text)); + QVERIFY2(mixed < alternative, "multipart/mixed must wrap the body, not sit beside it"); + QVERIFY2(text.contains(QStringLiteral("notes.txt")), qPrintable(text)); + QVERIFY2(text.contains(QStringLiteral("Content-Disposition: attachment")), qPrintable(text)); +} + +/// A file can vanish between being attached and being sent, so existence is +/// checked at BUILD time. The build must produce NOTHING sendable: an empty +/// `bytes` is what stops a caller that only checks for content from shipping a +/// message missing the thing it was written to carry. +void TestMessageBuilder::aMissingAttachmentFailsTheBuild() +{ + OutgoingMessage m = baseMessage(); + m.attachments = QStringList{QStringLiteral("/nonexistent/path/to/report.pdf")}; + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY(!r.ok()); + QVERIFY(r.bytes.isEmpty()); + QVERIFY2(r.error.contains(QStringLiteral("report.pdf")), qPrintable(r.error)); +} + +/// Measured 2026-08-20: GMime generates neither header unless asked. A message +/// without a Message-ID cannot be threaded by anything that receives it, +/// including this application's own notmuch index once the sent copy lands. +void TestMessageBuilder::everyMessageCarriesADateAndMessageId() +{ + const MessageBuilder::Result r = MessageBuilder::build(baseMessage(), m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY2(text.contains(QStringLiteral("Date: ")), qPrintable(text)); + QVERIFY2(text.contains(QStringLiteral("Message-Id: "), Qt::CaseInsensitive), qPrintable(text)); + QVERIFY(!r.messageId.isEmpty()); +} + +/// Bcc must be PRESENT in the bytes. The documented send command is `msmtp -t`, +/// which reads its recipients FROM the headers and strips Bcc itself before +/// transmission. Removing it here would mean blind recipients never receive the +/// message at all, silently. +/// +/// If a later change passes recipients as command arguments instead of relying +/// on -t, this test must change with it: under that scheme leaving Bcc in the +/// bytes discloses the blind recipients to everyone. +void TestMessageBuilder::recipientsAppearInTheirOwnHeaders() +{ + OutgoingMessage m = baseMessage(); + m.to = QStringList{QStringLiteral("to@example.org")}; + m.cc = QStringList{QStringLiteral("cc@example.org")}; + m.bcc = QStringList{QStringLiteral("bcc@example.org")}; + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY2(text.contains(QStringLiteral("From: ")), qPrintable(text)); + QVERIFY2(text.contains(QStringLiteral("user@example.org")), qPrintable(text)); + + const QRegularExpression to(QStringLiteral("^To:.*to@example\\.org"), + QRegularExpression::MultilineOption); + const QRegularExpression cc(QStringLiteral("^Cc:.*cc@example\\.org"), + QRegularExpression::MultilineOption); + const QRegularExpression bcc(QStringLiteral("^Bcc:.*bcc@example\\.org"), + QRegularExpression::MultilineOption); + QVERIFY2(to.match(text).hasMatch(), qPrintable(text)); + QVERIFY2(cc.match(text).hasMatch(), qPrintable(text)); + QVERIFY2(bcc.match(text).hasMatch(), qPrintable(text)); +} + +/// Config::account() returns a DEFAULT-CONSTRUCTED Account for an unknown key +/// rather than failing, so without this guard a bad key would build a message +/// with an empty From: silently malformed mail rather than a refusal, handed to +/// the send command as though it were fine. +void TestMessageBuilder::anAccountWithNoAddressFailsRatherThanBuildingHeaderlessMail() +{ + const Account empty; + const MessageBuilder::Result r = MessageBuilder::build(baseMessage(), empty); + QVERIFY(!r.ok()); + QVERIFY(r.bytes.isEmpty()); +} + +QTEST_MAIN(TestMessageBuilder) +#include "test_messagebuilder.moc" diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index 4dbc62d..e4772bf 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -1234,6 +1234,22 @@ Rule '%1': adds and removes nothing; dropped Regola '%1': non aggiunge né rimuove nulla; scartata + + The account has no address configured, so no message can be sent from it. + L'account non ha un indirizzo configurato, quindi non è possibile inviare messaggi da esso. + + + The attachment %1 is missing or unreadable. + L'allegato %1 è mancante o non leggibile. + + + The attachment %1 could not be read. + Non è stato possibile leggere l'allegato %1. + + + The message could not be assembled. + Non è stato possibile comporre il messaggio. + QueryCompleter -- cgit v1.2.3 From 9b1b371856c148dc668587254c51134ca9d4b605 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 21 Aug 2026 09:36:04 +0200 Subject: feat(compose): save drafts atomically into a Maildir, item 123 DraftStore::write() renders a built message into /cur with the given Maildir flags, writing through QSaveFile and unlinking the previous revision only after the new file is in place. Two orderings here are load-bearing and both are covered by a test that was checked against the mutation that breaks it. The unlink runs only after the write has succeeded, so a failed save leaves the previous revision intact rather than losing both. Provoking that failure needs care: the plan's version used an unwritable path where mkpath() fails and the function returns before reaching either the write or the unlink, so a mutation moving the unlink up survived it. The test uses an existing but read-only cur/ instead, where the failure lands at the write. And the size comparison stays ahead of commit() in the condition, because QSaveFile::commit() returns true after a short write and renames the truncated bytes into place: measured, write 4096 of 65536 with commit reporting true and the file left in the listing. What leaves the directory empty is the short-circuit returning before commit() is reached, after which ~QSaveFile() discards the uncommitted scratch file. Reducing the condition to !file.commit() looks like a simplification and writes a truncated draft into cur/, where notmuch would index it and mbsync would upload it. Resolving the mail root stays the caller's job, per item 124; this takes an absolute folder path and composes nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QP2g3b3kuLx6AYFCNEz6UR --- src/CMakeLists.txt | 1 + src/draftstore.cpp | 82 +++++++++++++++ src/draftstore.h | 60 +++++++++++ tests/CMakeLists.txt | 1 + tests/test_draftstore.cpp | 255 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 399 insertions(+) create mode 100644 src/draftstore.cpp create mode 100644 src/draftstore.h create mode 100644 tests/test_draftstore.cpp (limited to 'tests/CMakeLists.txt') diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b7a5be2..a4d7c55 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -13,6 +13,7 @@ add_library(qtmaildir_lib STATIC carddelegate.cpp notmuchworker.cpp maildirname.cpp + draftstore.cpp tagchip.cpp tagcolors.cpp savequerydialog.cpp diff --git a/src/draftstore.cpp b/src/draftstore.cpp new file mode 100644 index 0000000..d458bff --- /dev/null +++ b/src/draftstore.cpp @@ -0,0 +1,82 @@ +/* + * 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 "draftstore.h" + +#include "maildirname.h" + +#include +#include +#include +#include + +DraftStore::Result DraftStore::write(const QString &folderPath, + const QByteArray &bytes, + const QString &flags, + const QString &previousPath) +{ + Result result; + + if (folderPath.isEmpty()) { + result.error = QObject::tr("No folder was configured to write to."); + return result; + } + + // cur/, never new/. A file in new/ is re-announced as fresh mail by every + // reader of the Maildir, so an autosaved draft would arrive as a new + // message on every revision. + const QString curPath = folderPath + QStringLiteral("/cur"); + if (!QDir().mkpath(curPath)) { + result.error = QObject::tr("Cannot create the folder %1.").arg(curPath); + return result; + } + + // A FRESH name, with no previous one to preserve flags from: a draft is + // newly composed, and MessageBuilder's bytes carry no filename. The flags + // are appended here instead. + const QString name = MaildirName::fresh(QString()) + + QStringLiteral(":2,") + flags; + const QString target = curPath + QLatin1Char('/') + name; + + // QSaveFile: writes to a temporary and renames into place, so a reader + // never sees a half-written message. mbsync and notmuch both watch this + // directory. + QSaveFile file(target); + if (!file.open(QIODevice::WriteOnly)) { + result.error = QObject::tr("Cannot write to %1: %2") + .arg(target, file.errorString()); + return result; + } + + if (file.write(bytes) != bytes.size() || !file.commit()) { + result.error = QObject::tr("Cannot write to %1: %2") + .arg(target, file.errorString()); + return result; + } + + result.path = target; + + // AFTER the new file is safely in place, never before: unlinking first + // would lose the draft entirely if the write then failed. A failure to + // remove the old revision is not reported as a failure of the write, + // because the new revision IS on disk; the cost is one stale file. + if (!previousPath.isEmpty() && previousPath != target) + QFile::remove(previousPath); + + return result; +} diff --git a/src/draftstore.h b/src/draftstore.h new file mode 100644 index 0000000..13147c7 --- /dev/null +++ b/src/draftstore.h @@ -0,0 +1,60 @@ +/* + * 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. + */ + +#pragma once + +#include +#include + +/// Writes message bytes into a Maildir folder. +/// +/// Drafts and sent copies are the same operation into different folders with +/// different flags, so they are one unit. Nothing here calls notmuch: the +/// files become visible on the next sync, which keeps the read-only-by-default +/// rule intact and needs no write lock. +class DraftStore +{ +public: + struct Result + { + QString path; ///< The file written. Empty on failure. + QString error; ///< Empty on success. + + bool ok() const { return error.isEmpty(); } + }; + + /// Writes \p bytes into \p folderPath, an absolute Maildir folder. + /// + /// The folder is the CALLER's to resolve, and item 124 is why it is not + /// resolved here: the mail root comes from + /// `notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT)`, never from + /// `notmuch_database_get_path()`, which under a split index returns the + /// Xapian directory. A store that composed its own path from the wrong + /// accessor would write drafts into the index tree. + /// + /// \p flags is the Maildir flag string without the `:2,` prefix: "D" for a + /// draft, "S" for a sent copy. + /// + /// \p previousPath, when not empty, is unlinked AFTER the new file is + /// safely in place. Maildir has no in-place edit, so a draft rewritten + /// every thirty seconds would otherwise accumulate one file per pause. + /// The order matters: unlinking first would lose the draft entirely if the + /// write then failed. + static Result write(const QString &folderPath, const QByteArray &bytes, + const QString &flags, const QString &previousPath = {}); +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8c6231d..367d23d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -71,6 +71,7 @@ add_qtmaildir_test(messagedetailsdialog) add_qtmaildir_test(markdownrenderer) add_qtmaildir_test(messagebuilder) add_qtmaildir_test(maildirname) +add_qtmaildir_test(draftstore) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up diff --git a/tests/test_draftstore.cpp b/tests/test_draftstore.cpp new file mode 100644 index 0000000..5818261 --- /dev/null +++ b/tests/test_draftstore.cpp @@ -0,0 +1,255 @@ +/* + * 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 + +#include +#include + +#include "draftstore.h" + +class TestDraftStore : public QObject +{ + Q_OBJECT + +private slots: + void aWriteLandsInCurWithTheGivenFlags(); + void twoWritesProduceDistinctFiles(); + void thePreviousRevisionIsUnlinked(); + void theNewFileExistsBeforeTheOldOneGoes(); + void anUnwritableDirectoryReportsRatherThanThrows(); + void theFolderIsCreatedWhenAbsent(); + void theBytesAreWrittenVerbatim(); + void aFailedWriteLeavesNoFileBehind(); + void anEmptyFolderPathReportsRatherThanWriting(); +}; + +void TestDraftStore::aWriteLandsInCurWithTheGivenFlags() +{ + // cur/, never new/. A file dropped in new/ is re-announced as fresh mail + // by every reader of the Maildir, so a draft would arrive as a new + // message every time it autosaved. + QTemporaryDir dir; + const DraftStore::Result result = DraftStore::write( + dir.path(), QByteArray("From: a@example.org\r\n\r\nbody\r\n"), + QStringLiteral("D")); + + QVERIFY2(result.ok(), qPrintable(result.error)); + QVERIFY2(result.path.contains(QStringLiteral("/cur/")), + qPrintable(QStringLiteral("not written to cur/: %1").arg(result.path))); + QVERIFY2(result.path.endsWith(QStringLiteral(":2,D")), + qPrintable(QStringLiteral("flags missing: %1").arg(result.path))); + QVERIFY(QFile::exists(result.path)); +} + +void TestDraftStore::twoWritesProduceDistinctFiles() +{ + QTemporaryDir dir; + const DraftStore::Result first = DraftStore::write( + dir.path(), QByteArray("one"), QStringLiteral("D")); + const DraftStore::Result second = DraftStore::write( + dir.path(), QByteArray("two"), QStringLiteral("D")); + + QVERIFY(first.ok() && second.ok()); + QVERIFY2(first.path != second.path, + "two writes in the same second produced the same filename"); +} + +void TestDraftStore::thePreviousRevisionIsUnlinked() +{ + // Otherwise a draft autosaved every thirty seconds accumulates one file + // per pause, and every one of them syncs to the server. + QTemporaryDir dir; + const DraftStore::Result first = DraftStore::write( + dir.path(), QByteArray("revision one"), QStringLiteral("D")); + QVERIFY(first.ok()); + + const DraftStore::Result second = DraftStore::write( + dir.path(), QByteArray("revision two"), QStringLiteral("D"), first.path); + QVERIFY(second.ok()); + + QVERIFY2(!QFile::exists(first.path), + "the previous draft revision was left behind"); + QVERIFY(QFile::exists(second.path)); +} + +void TestDraftStore::theNewFileExistsBeforeTheOldOneGoes() +{ + // The ordering that matters: unlinking first would lose the draft + // entirely if the write then failed. + // + // The failure has to happen at the WRITE, not before it. A destination + // whose mkpath() fails returns too early to reach either ordering, so a + // mutation moving the unlink ahead of the write still passes: measured, + // "11 passed, 0 failed" with the unlink moved above the QSaveFile. The + // seam is a cur/ that exists and is read-only, which mkpath() reports as + // success (it is already there) and QSaveFile then refuses with + // "Permission denied". + QTemporaryDir good; + const DraftStore::Result first = DraftStore::write( + good.path(), QByteArray("precious"), QStringLiteral("D")); + QVERIFY(first.ok()); + + QTemporaryDir hostile; + const QString cur = hostile.path() + QStringLiteral("/cur"); + QVERIFY(QDir().mkpath(cur)); + QVERIFY(QFile::setPermissions(cur, QFile::ReadOwner | QFile::ExeOwner)); + + const DraftStore::Result failed = DraftStore::write( + hostile.path(), QByteArray("replacement"), QStringLiteral("D"), + first.path); + + // Restored before any assertion, so a failing assertion does not leave a + // directory QTemporaryDir cannot clean up. + QFile::setPermissions(cur, QFile::ReadOwner | QFile::WriteOwner + | QFile::ExeOwner); + + QVERIFY2(!failed.ok(), "a write into an unwritable cur/ reported success"); + QVERIFY2(QFile::exists(first.path), + "the previous revision was unlinked even though the new write failed"); +} + +void TestDraftStore::anUnwritableDirectoryReportsRatherThanThrows() +{ + const DraftStore::Result result = DraftStore::write( + QStringLiteral("/proc/nonexistent-and-unwritable"), + QByteArray("body"), QStringLiteral("D")); + + QVERIFY2(!result.ok(), "an unwritable directory reported success"); + QVERIFY2(!result.error.isEmpty(), "a failure carried no message to show"); + QVERIFY(result.path.isEmpty()); +} + +void TestDraftStore::theFolderIsCreatedWhenAbsent() +{ + // A configured drafts folder that does not exist yet is ordinary on a + // fresh account. Note the asymmetry with the trash folder: creating a + // folder here is safe because the NAME came from configuration and is + // validated at load, not composed from a tag. + QTemporaryDir dir; + const QString nested = dir.filePath(QStringLiteral("Drafts")); + const DraftStore::Result result = DraftStore::write( + nested, QByteArray("body"), QStringLiteral("D")); + + QVERIFY2(result.ok(), qPrintable(result.error)); + QVERIFY(QDir(nested + QStringLiteral("/cur")).exists()); +} + +void TestDraftStore::theBytesAreWrittenVerbatim() +{ + // A draft must be byte-identical to what would be sent, so nothing here + // may re-encode, add a trailing newline, or translate line endings. + QTemporaryDir dir; + const QByteArray bytes("From: a@example.org\r\nSubject: x\r\n\r\nbody\r\n"); + const DraftStore::Result result = + DraftStore::write(dir.path(), bytes, QStringLiteral("D")); + QVERIFY(result.ok()); + + QFile file(result.path); + QVERIFY(file.open(QIODevice::ReadOnly)); + QCOMPARE(file.readAll(), bytes); +} + +void TestDraftStore::aFailedWriteLeavesNoFileBehind() +{ + // A Maildir reader scans cur/ and indexes whatever it finds, so a write + // that fails PART WAY THROUGH must leave nothing, not a truncated + // message. A truncated message is the worse outcome by far: it is a + // plausible file that notmuch indexes and mbsync uploads. + // + // The failure has to land after a successful open() or it proves nothing + // about the QSaveFile choice: an unwritable directory refuses a plain + // QFile at open() too, and both then leave the directory empty. Measured + // that way, a mutation swapping QSaveFile for QFile passed. + // + // RLIMIT_FSIZE opens the real case. With the limit below the payload the + // open succeeds and write() returns short: measured, a plain QFile leaves + // a 4096-byte file in the listing, while the store leaves nothing. + // + // What produces that nothing is the ORDER of the condition, not the + // choice of QSaveFile, and getting this backwards is the dangerous + // reading. commit() is NOT the protection: called after a short write it + // returns true and renames the truncated bytes into place, measured as + // "write 4096 of 65536, commit true" with the directory then holding that + // file. The store never reaches it, because comparing write()'s return + // against the payload size short-circuits the `||` first and returns; the + // scratch file is then discarded by ~QSaveFile() having never been + // committed, and the listing is empty. + // + // So the size comparison must stay AHEAD of commit() in that condition. + // Reducing `write(bytes) != bytes.size() || !file.commit()` to + // `!file.commit()` looks like a simplification and writes a truncated + // draft into cur/, where notmuch indexes it and mbsync uploads it. + // + // The signal must be ignored before the limit is set, or the process is + // killed by SIGXFSZ rather than seeing a short write. + QTemporaryDir dir; + + struct rlimit previous; + QVERIFY(getrlimit(RLIMIT_FSIZE, &previous) == 0); + void (*previousHandler)(int) = signal(SIGXFSZ, SIG_IGN); + + struct rlimit limited; + limited.rlim_cur = 4096; + limited.rlim_max = previous.rlim_max; + QVERIFY(setrlimit(RLIMIT_FSIZE, &limited) == 0); + + const DraftStore::Result result = DraftStore::write( + dir.path(), QByteArray(64 * 1024, 'x'), QStringLiteral("D")); + + // Restored before any assertion, so a failing one does not leave the rest + // of the suite unable to write a file. + setrlimit(RLIMIT_FSIZE, &previous); + signal(SIGXFSZ, previousHandler); + + QVERIFY2(!result.ok(), "a truncated write reported success"); + QVERIFY(result.path.isEmpty() || !QFile::exists(result.path)); + + const QStringList entries = + QDir(dir.path() + QStringLiteral("/cur")).entryList(QDir::Files); + QVERIFY2(entries.isEmpty(), + qPrintable(QStringLiteral("a truncated write left a message " + "behind for notmuch to index: %1") + .arg(entries.join(QLatin1Char(' '))))); +} + +void TestDraftStore::anEmptyFolderPathReportsRatherThanWriting() +{ + // An account with no drafts folder configured reaches here with an empty + // string. Without the guard the destination becomes "/cur", an absolute + // path at the root of the filesystem, and the only thing stopping the + // write is that this process does not run as root. That is not a + // safeguard, so the guard is asserted on its own MESSAGE rather than on + // the failure: a refusal naming the missing configuration is a different + // outcome from a permission error, and only the first survives being run + // by a privileged user. + const DraftStore::Result result = + DraftStore::write(QString(), QByteArray("body"), QStringLiteral("D")); + + QVERIFY2(!result.ok(), "an empty folder path reported success"); + QVERIFY(result.path.isEmpty()); + QVERIFY2(!result.error.contains(QStringLiteral("/cur")), + qPrintable(QStringLiteral( + "the empty path reached the filesystem instead of being " + "refused: %1").arg(result.error))); + QVERIFY(!result.error.isEmpty()); +} + +QTEST_MAIN(TestDraftStore) +#include "test_draftstore.moc" -- cgit v1.2.3 From 6488810c779970094b86079c8688d83d8529fab0 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 21 Aug 2026 10:12:26 +0200 Subject: feat(compose): hand outgoing mail to the send command, item 123 MessageSender runs the configured command with the message on stdin and judges the result by its exit status alone. Nothing here waits on the event loop, so a send does not block the GUI thread; a 1.6MB payload was probed through a reading stub without deadlocking the pipe buffer. The command is split and passed to QProcess as a program and an argument list, never through a shell. A test asserts that by giving the command shell metacharacters and checking that the marker file a shell would have created does not exist, so the property fails a mutation rather than resting on a comment. Four corrections to the plan's draft. splitCommand handles double quotes only, so a single-quoted argument splits wrongly and the header now says so. A crashing command delivers finished(11, CrashExit) and would have been reported as "exited with status 11", so a crash branch was added. A command that exits without draining a large stdin emits WriteError before finished(), which the draft handled correctly and by luck, untested. And an empty send_command is checked after trimming. Two contract gaps found in review, both about what this class promises rather than what it does. The exactly-once guarantee covers the EMIT, not what a caller receives: a long-lived sender plus a connect() inside each send accumulates receivers, and the second result then runs the first send's lambda too, filing a sent copy of the wrong message. The header now scopes the promise and requires Qt::SingleShotConnection. The plan's Task 11 call site already had that flag, sixty-nine lines below the connect and outside anything a reader would see, so the plan gained a note where someone retyping it will read it. And destruction mid-send killed the command with no report, announced only by a Qt warning: a live SMTP conversation abandoned, possibly partially delivered, while the user believes it was cancelled. The destructor now closes stdin, waits a bounded five seconds, and only then kills. It emits nothing either way, because the outcome after a kill is genuinely unknown and reporting "not sent" for a message that may have gone out is the mailsync.sh mistake pointing the other way. Claiming m_reported before kill() is what makes that true, since kill() delivers finished(CrashExit), which would otherwise emit exactly that untruth. No timeout on the send itself: killing a slow but working send is worse than waiting. Task 10 owns the popup, and deliberately offers no cancel after commit, so this class promises none either. Also refreshes the translations Task 5 left out. That gap was invisible because test_translations builds its rows from the .ts file, so a string that never entered it is never asserted on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QP2g3b3kuLx6AYFCNEz6UR --- .../plans/2026-08-20-compose-and-send.md | 28 ++ src/CMakeLists.txt | 1 + src/messagesender.cpp | 197 ++++++++ src/messagesender.h | 164 +++++++ tests/CMakeLists.txt | 1 + tests/test_messagesender.cpp | 532 +++++++++++++++++++++ translations/qtmaildir_it_IT.ts | 27 ++ 7 files changed, 950 insertions(+) create mode 100644 src/messagesender.cpp create mode 100644 src/messagesender.h create mode 100644 tests/test_messagesender.cpp (limited to 'tests/CMakeLists.txt') diff --git a/docs/superpowers/plans/2026-08-20-compose-and-send.md b/docs/superpowers/plans/2026-08-20-compose-and-send.md index e7eddfa..7d5f6f1 100644 --- a/docs/superpowers/plans/2026-08-20-compose-and-send.md +++ b/docs/superpowers/plans/2026-08-20-compose-and-send.md @@ -4008,6 +4008,20 @@ private: `src/composewindow.cpp`. The full file is long; these are the parts that carry decisions, and the rest is ordinary widget assembly. +**One thing in this block is load-bearing and easy to drop while retyping it: +the `Qt::SingleShotConnection` on the `MessageSender::finished` connect inside +the `committed` handler.** `m_sender` is a long-lived member, so a plain +`connect()` beside a `send()` call leaks a receiver per send and the second +result runs every earlier lambda, each still holding an earlier message's bytes +by value: a sent copy of the wrong message, and `accept()` on a destroyed +dialog. `MessageSender`'s own once-only guard cannot help, because that guards +the emit and this is one emit reaching many receivers. The header for +`MessageSender::finished` states the rule and +`test_messagesender.cpp::aPerSendConnectionMustBeSingleShot` measures it (3 +deliveries for 2 sends without the flag, 2 with it). Noted here because the +plan's code blocks are drafts and this is the line whose absence still +compiles, still runs, and is wrong only on the second send. + ```cpp #include "composewindow.h" @@ -4169,6 +4183,20 @@ void ComposeWindow::send() connect(dialog, &SendDialog::committed, this, [this, dialog, built, account]() { m_sender->send(account.sendCommand, built.bytes); + // Qt::SingleShotConnection IS REQUIRED HERE, and this line is the + // correction of a defect that was in this plan's draft (found while + // building Task 6, 2026-08-21). m_sender is a long-lived member, so a + // bare connect() beside each send() accumulates a permanent receiver + // per send. Send, fail, correct the recipient, send again, and the + // second result runs BOTH lambdas: the first still holds the FIRST + // message's `built` and `account` by value, so it files a sent copy of + // the wrong message and calls accept() on a dialog it already + // deleteLater()'d. MessageSender's m_reported guard cannot prevent + // this: it collapses two QProcess signals into one emit, and this is + // one emit reaching many receivers. Measured in + // test_messagesender.cpp::aPerSendConnectionMustBeSingleShot, where + // the bare shape delivers 3 results for 2 sends and the single-shot + // shape delivers 2. connect(m_sender, &MessageSender::finished, this, [this, dialog, built, account](bool sent, const QString &error) { if (!sent) { diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a4d7c55..eac2fab 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -14,6 +14,7 @@ add_library(qtmaildir_lib STATIC notmuchworker.cpp maildirname.cpp draftstore.cpp + messagesender.cpp tagchip.cpp tagcolors.cpp savequerydialog.cpp diff --git a/src/messagesender.cpp b/src/messagesender.cpp new file mode 100644 index 0000000..f336028 --- /dev/null +++ b/src/messagesender.cpp @@ -0,0 +1,197 @@ +/* + * 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 "messagesender.h" + +MessageSender::MessageSender(QObject *parent) + : QObject(parent) +{ + // Separate channels, unlike MailSync's MergedChannels: there is no log + // pane to fill here, and stderr alone is what a failure has to report. + // Merging them would put the command's ordinary chatter into the error + // message shown for a rejected send. + m_process.setProcessChannelMode(QProcess::SeparateChannels); + + connect(&m_process, &QProcess::finished, + this, &MessageSender::handleFinished); + connect(&m_process, &QProcess::errorOccurred, + this, &MessageSender::handleError); +} + +MessageSender::~MessageSender() +{ + if (m_process.state() == QProcess::NotRunning) + return; + + // A send is a live SMTP conversation and abandoning one has a genuinely + // unknown outcome, so give the command a bounded chance to finish rather + // than killing it outright. Measured: without this, a one-second command + // destroyed 100ms in is killed and its work does not complete, announced + // only by a Qt warning on stderr. With it, the same command completes and + // the destructor costs the ~1s the command actually needed. + // + // The write channel is closed first because the command may still be + // reading: a command blocked on stdin would otherwise never reach EOF and + // would burn the whole timeout for no reason. + m_process.closeWriteChannel(); + if (m_process.waitForFinished(kShutdownWaitMs)) + return; + + // Still running. A destructor cannot block a quitting application forever, + // so the process is killed deliberately here rather than by ~QProcess. + // + // NOTHING IS EMITTED. The outcome after a kill is unknown: the message may + // have been fully delivered, partially delivered, or not sent at all, and + // this class reports two outcomes only. Emitting finished(false, ...) would + // report "not sent" for a message that may well have been, which is the + // mailsync.sh mistake pointing the other way. Emitting finished(true, ...) + // would be worse. A caller that must know has to keep this object alive + // until finished() arrives. + // + // Claiming the report BEFORE the kill is what makes that true, and it is + // not optional: kill() makes QProcess deliver finished(CrashExit), which + // reaches handleFinished and would emit exactly the untruthful "not sent" + // this comment forbids. Measured, by a test that failed against the + // version without these two lines. This is also the one place m_reported + // does live work, rather than the defence-in-depth it is on the signal + // paths. + m_reported = true; + m_process.kill(); + m_process.waitForFinished(kShutdownWaitMs); +} + +bool MessageSender::isRunning() const +{ + return m_process.state() != QProcess::NotRunning; +} + +bool MessageSender::send(const QString &command, const QByteArray &bytes) +{ + if (command.trimmed().isEmpty() || isRunning()) + return false; + + // splitCommand gives an argument list; running through a shell would make + // every recipient address, display name and config value a potential + // injection point. QProcess hands the list to execve, so a `;` or a + // `$(...)` in the configured command is a literal argument with nothing to + // interpret it. Note that splitCommand strips DOUBLE quotes only. + // + // Nothing from the message reaches the argument list at all: the command + // reads its recipients from the message's own headers, which is what `-t` + // means in the documented example. + const QStringList parts = QProcess::splitCommand(command); + if (parts.isEmpty()) + return false; + + m_command = command; + m_reported = false; + + m_process.setProgram(parts.first()); + m_process.setArguments(parts.mid(1)); + + // Deliberately no waitForStarted(): this runs on the GUI thread and the + // interface must stay responsive while a send is in flight. A failed + // launch arrives via errorOccurred(FailedToStart) instead, which QProcess + // emits INSTEAD OF finished() rather than before it (measured). + m_process.start(); + + // Written after start() and before the process has necessarily launched, + // which is safe: QProcess buffers and drains as the reader consumes. + // Measured with a 320KB payload against a `cat` stub, which arrived + // byte-identical, so a message with an attachment does not deadlock on the + // 64KB pipe buffer. + m_process.write(bytes); + + // The message goes on stdin and the channel is closed, so a command + // reading to EOF terminates. Without closeWriteChannel() a command like + // `cat` waits forever and the popup never leaves its Sending stage. + m_process.closeWriteChannel(); + + return true; +} + +void MessageSender::handleFinished(int exitCode, QProcess::ExitStatus status) +{ + // errorOccurred may already have reported this failure. Reporting twice + // would close the popup and then act on a second result. + // + // This guard IS load-bearing, on exactly one path: the destructor sets + // m_reported before kill(), because kill() makes QProcess deliver + // finished(CrashExit) and without the flag this handler would emit a + // "not sent" for a message whose fate is genuinely unknown. A test fails + // against its removal. + // + // On the two signal paths it is defence in depth and currently cannot + // fire: handleError is filtered to FailedToStart, and FailedToStart is + // never followed by finished() (measured). An instrumented run of the + // whole suite recorded zero hits there, including on the crash and + // write-error paths that DO emit both signals. It stays because the day + // someone widens handleError to report another error, the double report is + // silent and costs a duplicate sent copy. + if (m_reported) + return; + m_reported = true; + + // The exit status is the only authority. Nothing is inferred from what the + // command printed: mailsync.sh records what a wrong answer here costs, and + // a send reported as succeeding files a sent copy for a message that never + // left the machine. + const bool sent = status == QProcess::NormalExit && exitCode == 0; + if (sent) { + emit finished(true, QString()); + return; + } + + // Exit 75 is deliberately NOT special. See the header. + QString error = QString::fromUtf8(m_process.readAllStandardError()).trimmed(); + if (error.isEmpty()) { + // A failure with a blank explanation gives the user nothing to act on, + // so the status stands in for the reason the command did not give. + error = status == QProcess::CrashExit + ? tr("The send command crashed.") + : tr("The send command exited with status %1 and said nothing.") + .arg(exitCode); + } + emit finished(false, error); +} + +void MessageSender::handleError(QProcess::ProcessError error) +{ + // QProcess emits errorOccurred(FailedToStart) INSTEAD OF finished(), so + // without this the caller waits forever. Measured on Qt 6.11 for both a + // missing binary and a non-executable file: one errorOccurred, no + // finished(). + // + // Every other error IS followed by finished() and is left to it, which is + // not merely tidiness. A command that exits without draining a large stdin + // emits errorOccurred(WriteError) and then finished() with the command's + // real exit code and its real stderr; reporting the write error here would + // replace the server's own rejection message with a plumbing detail, and + // reporting it as well as finished() would deliver two results for one + // message. + if (error != QProcess::FailedToStart) + return; + if (m_reported) + return; + m_reported = true; + + emit finished(false, + tr("The send command '%1' could not be started. Check that " + "the path is correct and the file is executable.") + .arg(m_command)); +} diff --git a/src/messagesender.h b/src/messagesender.h new file mode 100644 index 0000000..86dde68 --- /dev/null +++ b/src/messagesender.h @@ -0,0 +1,164 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include + +/// Runs an account's send_command with the message on stdin. +/// +/// EXACTLY TWO OUTCOMES: sent, or not sent with a reason. Exit code 75 has no +/// special meaning here, unlike in the sync path. Item 125 is open precisely +/// because mailsync.sh treats 75 as neither success nor failure and hangs on +/// it; that exists because the script contends for a lock and there is no lock +/// here. Recorded so the two paths are not later "harmonised". +/// +/// **The exit status is the only authority on whether a message was sent.** +/// This is the same rule assets/mailsync.sh exists to honour, and the same +/// class of bug is available here: a sender that reported success on anything +/// other than exit 0 would file a sent copy and close the composer for a +/// message that never left the machine. Nothing is derived from the command's +/// output, which belongs to whatever the user installed behind send_command. +/// +/// **No shell, ever.** The command is a config value and is split into an +/// argument list with QProcess::splitCommand, then handed to QProcess, which +/// calls execve directly. A `;`, `&&`, `$(...)` or a backtick in the +/// configured string therefore arrives as a literal argument with nothing to +/// interpret it. Note that splitCommand understands DOUBLE quotes only: +/// `-a 'my acct'` splits into three arguments, so a path or an argument +/// containing a space must be written with double quotes. Measured, not +/// assumed. +/// +/// **No message content ever reaches the argument list.** The bytes go on +/// stdin and only on stdin; the command reads its recipients from the +/// message's own headers, which is what `-t` means in the documented example. +/// A recipient address or a display name therefore cannot become an argument +/// however it is spelled. +/// +/// This is the outbox seam. An outbox is built by calling this from a drain +/// loop; nothing in the composer would need to change. +/// +/// Nothing here blocks the GUI thread DURING a send. send() hands the process +/// to the event loop and returns; there is no waitForStarted() and no +/// waitForFinished() on that path, so a command that hangs leaves the +/// interface responsive and the caller waiting on finished(). Timing a hung +/// command out is deliberately NOT this class's job: a timeout here would kill +/// a slow but working send. The one place this class does block is its +/// destructor, and that is the subject of the next paragraph. +/// +/// **Destruction mid-send waits, briefly, and then kills.** A send is a live +/// SMTP conversation, so the outcome of abandoning one is genuinely unknown: +/// the message may be fully delivered, partially delivered, or not sent at +/// all. Measured with a one-second command destroyed 100ms in: plain +/// destruction returns in 100ms, kills the child, and the work does NOT +/// complete, announced by nothing but a `QProcess: Destroyed while process is +/// still running` warning on stderr. That is the mailsync.sh failure in a new +/// place, an unknown real outcome reported as a definite one, and it is +/// reachable by closing the composer with the window manager's X button while +/// a send is in flight. +/// +/// So the destructor waits up to kShutdownWaitMs for the command to finish on +/// its own, which is the outcome that makes the report truthful: the same +/// measurement with a bounded wait completes the child and costs only the +/// ~1s the command actually needed. A command still running after that is +/// killed, because a destructor cannot block a quitting application forever. +/// +/// **No finished() is emitted from the destructor, in either branch, and that +/// is deliberate rather than an omission.** After a kill the outcome is +/// unknown, and this class reports two outcomes only; inventing a third by +/// guessing would be the exact lie the rest of this header is built to avoid. +/// After a successful late finish the emit would reach handlers on a +/// half-destroyed caller. A caller that must know the result has to keep the +/// sender alive until finished() arrives, which is what refusing to close a +/// composer mid-send would express. +/// +/// **There is no cancel(), and the caller does not have one either.** An +/// earlier revision of this comment deferred cancellation to "the caller's +/// popup", which overstated what exists: SendDialog offers an undo BEFORE the +/// send is committed and none after, by an explicit design decision that a +/// post-commit cancel is worse than either clean outcome. If a real cancel is +/// ever wanted it belongs HERE, killing the process and emitting one +/// finished(false, ...) through m_reported, which is the shape that flag +/// already has. It is not built now, and this header does not promise it. +class MessageSender : public QObject +{ + Q_OBJECT + +public: + explicit MessageSender(QObject *parent = nullptr); + + /// Waits briefly for an in-flight send, then kills it. See the class + /// comment: this is the one blocking call in the class, and it emits + /// nothing. + ~MessageSender() override; + + /// How long the destructor gives an in-flight command to finish on its + /// own before killing it. Long enough for a local MTA handing off to a + /// queue, short enough not to hang a quitting application. + static constexpr int kShutdownWaitMs = 5000; + + /// Starts \p command with \p bytes on stdin. + /// + /// Returns false without emitting anything when the command is empty or + /// only whitespace, when it splits to nothing, or when a send is already + /// running. A true return means the process was handed to the event loop, + /// NOT that it launched: a missing or non-executable binary surfaces + /// asynchronously through finished(false, ...), exactly as MailSync + /// documents. + bool send(const QString &command, const QByteArray &bytes); + + bool isRunning() const; + +signals: + /// \p error is empty on success and carries the command's stderr, or a + /// description of why it could not start, on failure. + /// + /// EMITTED exactly once per accepted send, and the distinction between + /// emitted and RECEIVED is the whole of this paragraph. QProcess can report + /// both an error and a finish for one run (measured: a command that exits + /// without draining a large stdin emits errorOccurred(WriteError) and then + /// finished()), and m_reported collapses that to one emit. + /// + /// **m_reported guards the emit, not the receivers, and a caller can still + /// see one result twice.** A MessageSender is normally a long-lived member + /// reused for every send, so a caller that connects INSIDE its send path + /// adds a permanent connection each time: send, fail, correct the + /// recipient, send again, and the second result runs BOTH lambdas. The + /// first still holds the first message's bytes, so it files a sent copy of + /// the wrong message and acts on a dialog it already destroyed. That is + /// precisely the harm this signal's contract exists to prevent, arriving + /// by the one route no guard inside this class can cover. + /// + /// A caller connecting per-send must therefore pass + /// `Qt::SingleShotConnection` (Qt 6.0+; this project is on 6.11), which + /// disconnects the moment the lambda runs. Connecting ONCE in the caller's + /// constructor and keeping the per-send state in members is the other + /// correct shape. What is not correct, and what reads as permitted if this + /// paragraph is skipped, is a bare connect() next to a send() call. + void finished(bool sent, const QString &error); + +private: + void handleFinished(int exitCode, QProcess::ExitStatus status); + void handleError(QProcess::ProcessError error); + + QProcess m_process; + QString m_command; + bool m_reported = false; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 367d23d..e38d764 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -72,6 +72,7 @@ add_qtmaildir_test(markdownrenderer) add_qtmaildir_test(messagebuilder) add_qtmaildir_test(maildirname) add_qtmaildir_test(draftstore) +add_qtmaildir_test(messagesender) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up diff --git a/tests/test_messagesender.cpp b/tests/test_messagesender.cpp new file mode 100644 index 0000000..89e0fcf --- /dev/null +++ b/tests/test_messagesender.cpp @@ -0,0 +1,532 @@ +/* + * 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 + +#include "messagesender.h" + +class TestMessageSender : public QObject +{ + Q_OBJECT + +private slots: + void aSuccessfulCommandReportsSent(); + void theMessageArrivesOnStdinIntact(); + void aLargeMessageArrivesWhole(); + void aFailingCommandReportsItsStderr(); + void aCommandThatDoesNotExistReportsAFailure(); + void aCommandThatIsNotExecutableReportsAFailure(); + void anEmptyCommandIsRefusedWithoutRunning(); + void aCommandOfOnlyWhitespaceIsRefusedWithoutRunning(); + void exitCode75IsAnOrdinaryFailure(); + void aSilentFailureStillReportsAReason(); + void aCommandThatNeverReadsStdinIsStillJudgedByItsExitStatus(); + void aCrashedCommandIsAFailureWithAReason(); + void aSecondSendIsRefusedWhileOneIsRunning(); + void shellMetacharactersReachNoShell(); + void nothingIsEverReportedTwice(); + void destroyingTheSenderLetsAnInFlightSendFinish(); + void destroyingTheSenderEmitsNothing(); + void aPerSendConnectionMustBeSingleShot(); + +private: + QString writeStub(const QString &name, const QString &body, + bool executable = true); + + QTemporaryDir m_dir; +}; + +QString TestMessageSender::writeStub(const QString &name, const QString &body, + bool executable) +{ + const QString path = m_dir.filePath(name); + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) + return {}; + file.write(QStringLiteral("#!/bin/sh\n%1\n").arg(body).toUtf8()); + file.close(); + QFile::Permissions permissions = QFile::ReadOwner | QFile::WriteOwner; + if (executable) + permissions |= QFile::ExeOwner; + file.setPermissions(permissions); + return path; +} + +void TestMessageSender::aSuccessfulCommandReportsSent() +{ + const QString stub = writeStub(QStringLiteral("ok.sh"), QStringLiteral("cat >/dev/null")); + QVERIFY(!stub.isEmpty()); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, QByteArray("From: a@example.org\r\n\r\nbody\r\n"))); + + QVERIFY(spy.wait(5000)); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toBool(), true); + QVERIFY2(spy.at(0).at(1).toString().isEmpty(), + "a successful send carried an error message"); + QVERIFY2(!sender.isRunning(), "the sender still reports a run in progress"); +} + +void TestMessageSender::theMessageArrivesOnStdinIntact() +{ + // The property that matters most: the bytes the builder produced are the + // bytes the command receives. A stub that writes stdin to a file is the + // only way to see it, since there is no MTA to ask. + const QString captured = m_dir.filePath(QStringLiteral("captured.eml")); + const QString stub = writeStub(QStringLiteral("capture.sh"), + QStringLiteral("cat > '%1'").arg(captured)); + QVERIFY(!stub.isEmpty()); + + const QByteArray bytes( + "From: a@example.org\r\n" + "Subject: =?UTF-8?B?UGVyY2jDqQ==?=\r\n" + "\r\n" + "Perch=C3=A9 accented body.\r\n"); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, bytes)); + QVERIFY(spy.wait(5000)); + QCOMPARE(spy.at(0).at(0).toBool(), true); + + QFile file(captured); + QVERIFY2(file.open(QIODevice::ReadOnly), "the stub captured no stdin at all"); + QCOMPARE(file.readAll(), bytes); +} + +void TestMessageSender::aLargeMessageArrivesWhole() +{ + // A message with an attachment is megabytes, not bytes, and a pipe holds + // 64KB. If the write were not driven by the event loop the process would + // deadlock on a full pipe, or the tail would be silently dropped and a + // truncated message would be reported as sent. Measured: 1.6MB in one + // write() call returns the full count only because QProcess buffers it and + // drains it as the reader consumes; a probe confirmed the payload arrives + // byte-identical. + const QString captured = m_dir.filePath(QStringLiteral("big.eml")); + const QString stub = writeStub(QStringLiteral("bigcapture.sh"), + QStringLiteral("cat > '%1'").arg(captured)); + QVERIFY(!stub.isEmpty()); + + QByteArray bytes("From: a@example.org\r\n\r\n"); + // Well past a pipe buffer, and not a repeating single byte, so a partial + // write cannot accidentally compare equal. + for (int i = 0; i < 60000; ++i) + bytes += QByteArray::number(i) + "\r\n"; + QVERIFY(bytes.size() > 300000); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, bytes)); + QVERIFY(spy.wait(10000)); + QCOMPARE(spy.at(0).at(0).toBool(), true); + + QFile file(captured); + QVERIFY(file.open(QIODevice::ReadOnly)); + const QByteArray got = file.readAll(); + QCOMPARE(got.size(), bytes.size()); + QCOMPARE(got, bytes); +} + +void TestMessageSender::aFailingCommandReportsItsStderr() +{ + // stderr is shown verbatim: network errors, authentication failures and + // server rejections all belong to send_command, and this application + // deliberately does not interpret them. + const QString stub = writeStub( + QStringLiteral("fail.sh"), + QStringLiteral("cat >/dev/null; echo 'auth failed: bad password' >&2; exit 1")); + QVERIFY(!stub.isEmpty()); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, QByteArray("body"))); + + QVERIFY(spy.wait(5000)); + QCOMPARE(spy.at(0).at(0).toBool(), false); + QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("auth failed")), + qPrintable(QStringLiteral("stderr was not reported: '%1'") + .arg(spy.at(0).at(1).toString()))); +} + +void TestMessageSender::aCommandThatDoesNotExistReportsAFailure() +{ + // A typo'd path is the likely cause, so the message names the command. + // QProcess emits errorOccurred(FailedToStart) INSTEAD OF finished(), which + // is the trap MailSync already documents: without handling it the signal + // never arrives and the popup waits forever. Measured on Qt 6.11: + // finCount 0, errCount 1. + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(QStringLiteral("/nonexistent/msmtp"), QByteArray("body"))); + + QVERIFY2(spy.wait(5000), "no result was ever reported for a missing command"); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toBool(), false); + QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("msmtp")), + qPrintable(QStringLiteral("the error does not name the command: '%1'") + .arg(spy.at(0).at(1).toString()))); +} + +void TestMessageSender::aCommandThatIsNotExecutableReportsAFailure() +{ + // A separate case from a missing file and reached by an ordinary mistake: + // a script written by the user and never chmod'd. It also arrives as + // FailedToStart with no finished(), so the same handler covers it, but a + // test asserting only the missing-file case would pass against a handler + // keyed on the errno rather than on the error enum. + const QString stub = writeStub(QStringLiteral("noexec.sh"), + QStringLiteral("cat >/dev/null"), false); + QVERIFY(!stub.isEmpty()); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, QByteArray("body"))); + + QVERIFY2(spy.wait(5000), "no result was ever reported for a non-executable command"); + QCOMPARE(spy.at(0).at(0).toBool(), false); + QVERIFY(!spy.at(0).at(1).toString().isEmpty()); +} + +void TestMessageSender::anEmptyCommandIsRefusedWithoutRunning() +{ + // A receive-only account. The compose actions are disabled on its mail, so + // this should be unreachable; refusing here rather than asserting means a + // future caller cannot accidentally send from an account that cannot. + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY2(!sender.send(QString(), QByteArray("body")), + "an empty command was accepted"); + QCOMPARE(spy.count(), 0); + QVERIFY(!sender.isRunning()); +} + +void TestMessageSender::aCommandOfOnlyWhitespaceIsRefusedWithoutRunning() +{ + // A config file with `send_command = ` and a trailing space reaches + // exactly this, and it must not run anything. + // + // MEASURED, and worth stating precisely so this is not mistaken for a + // sharper test than it is: send() has TWO guards that both catch a blank + // command, the trimmed()-empty check and the parts.isEmpty() check after + // QProcess::splitCommand(" ") returns an empty list. Dropping either one + // alone leaves this test green, because the other still refuses. Dropping + // BOTH aborts the run outright: QProcess treats an empty program as fatal, + // and the mutation reports "Received a fatal error" rather than a failed + // comparison. The pair is what is under test here; the redundancy is + // deliberate, since the fatal path is the one thing a send must never + // reach. + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY2(!sender.send(QStringLiteral(" \t "), QByteArray("body")), + "a whitespace-only command was accepted"); + QCOMPARE(spy.count(), 0); + QVERIFY(!sender.isRunning()); +} + +void TestMessageSender::exitCode75IsAnOrdinaryFailure() +{ + // Explicitly asserted so the sync path's special handling of 75 is never + // copied here. There is no lock to contend for, so 75 means only what the + // command chose it to mean: not sent. + const QString stub = writeStub(QStringLiteral("busy.sh"), + QStringLiteral("cat >/dev/null; exit 75")); + QVERIFY(!stub.isEmpty()); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, QByteArray("body"))); + + QVERIFY(spy.wait(5000)); + QCOMPARE(spy.at(0).at(0).toBool(), false); +} + +void TestMessageSender::aSilentFailureStillReportsAReason() +{ + // The mailsync.sh lesson in the other direction: a command that fails + // without saying anything must not produce an empty error string, because + // the popup would then show a failure with a blank explanation and the + // user would have nothing to act on. + const QString stub = writeStub(QStringLiteral("silent.sh"), + QStringLiteral("cat >/dev/null; exit 3")); + QVERIFY(!stub.isEmpty()); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, QByteArray("body"))); + + QVERIFY(spy.wait(5000)); + QCOMPARE(spy.at(0).at(0).toBool(), false); + const QString error = spy.at(0).at(1).toString(); + QVERIFY2(!error.isEmpty(), "a silent failure reported no reason at all"); + QVERIFY2(error.contains(QStringLiteral("3")), + qPrintable(QStringLiteral("the exit status is not named: '%1'").arg(error))); +} + +void TestMessageSender::aCommandThatNeverReadsStdinIsStillJudgedByItsExitStatus() +{ + // Measured on Qt 6.11: a command that exits without draining a large stdin + // emits errorOccurred(WriteError) BEFORE finished(). A handler that treated + // any error as a failure to start would report the write error and swallow + // the real exit status; a handler that reported on every error would report + // twice. The exit status is the only authority, exactly as it is for the + // sync script, so this asserts the reason the command GAVE. + const QString stub = writeStub( + QStringLiteral("nonreading.sh"), + QStringLiteral("echo 'recipient rejected' >&2; exit 1")); + QVERIFY(!stub.isEmpty()); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, QByteArray(1600 * 1024, 'x'))); + + QVERIFY(spy.wait(5000)); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toBool(), false); + QVERIFY2(spy.at(0).at(1).toString().contains(QStringLiteral("recipient rejected")), + qPrintable(QStringLiteral("the command's own reason was lost: '%1'") + .arg(spy.at(0).at(1).toString()))); +} + +void TestMessageSender::aCrashedCommandIsAFailureWithAReason() +{ + // A segfaulting MTA is a real failure mode and reaches a DIFFERENT branch + // from a nonzero exit: status is CrashExit and exitCode carries the signal + // number, so an error message built from the exit code alone would tell the + // user the command "exited with status 11", which is not what happened. + // + // Measured on Qt 6.11: a crash emits errorOccurred(Crashed) and THEN + // finished(11, CrashExit). Only finished() reports, because handleError + // filters to FailedToStart, so the count assertion below also proves that + // filter is doing work on a path that is not the write-error one. + const QString stub = writeStub(QStringLiteral("crash.sh"), + QStringLiteral("cat >/dev/null; kill -SEGV $$")); + QVERIFY(!stub.isEmpty()); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, QByteArray("body"))); + + QVERIFY(spy.wait(5000)); + QTest::qWait(300); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toBool(), false); + const QString error = spy.at(0).at(1).toString(); + QVERIFY2(!error.isEmpty(), "a crashed command reported no reason"); + QVERIFY2(error.contains(QStringLiteral("crash")), + qPrintable(QStringLiteral("a crash was reported as an ordinary exit: '%1'") + .arg(error))); +} + +void TestMessageSender::aSecondSendIsRefusedWhileOneIsRunning() +{ + // One QProcess, so a second send would overwrite the first's program and + // arguments mid-flight. Refusing is what makes the popup's Sending stage + // mean one message. + const QString stub = writeStub(QStringLiteral("slow.sh"), + QStringLiteral("cat >/dev/null; sleep 1")); + QVERIFY(!stub.isEmpty()); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, QByteArray("first"))); + QVERIFY2(sender.isRunning(), "the sender does not report the run it just started"); + QVERIFY2(!sender.send(stub, QByteArray("second")), + "a second send was accepted while one was running"); + + QVERIFY(spy.wait(10000)); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toBool(), true); +} + +void TestMessageSender::shellMetacharactersReachNoShell() +{ + // The security property, asserted rather than asserted-about-in-a-comment. + // The command is split into an argument list and handed to execve, so a + // `;` in it is a literal argument and there is no shell to act on it. If + // this ever ran through `sh -c` the stub below would be invoked and the + // marker file would exist. + // + // Measured: QProcess::splitCommand("msmtp; rm x") yields ("msmtp;", "rm", + // "x"), so the semicolon does not even separate arguments. + const QString marker = m_dir.filePath(QStringLiteral("shell-ran")); + const QString stub = writeStub(QStringLiteral("args.sh"), + QStringLiteral("cat >/dev/null; exit 0")); + QVERIFY(!stub.isEmpty()); + + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(QStringLiteral("%1 ; touch %2").arg(stub, marker), + QByteArray("body"))); + QVERIFY(spy.wait(5000)); + + QVERIFY2(!QFile::exists(marker), + "the send command was interpreted by a shell"); + + // And the same string quoted the way a shell would need it also reaches no + // shell: double quotes are the ONLY quoting splitCommand understands. + // Measured: single quotes are NOT stripped, so `-a 'my acct'` arrives as + // three arguments. Recorded here because the plan's comment claimed + // splitCommand "handles quoted arguments" without that qualification. + QCOMPARE(QProcess::splitCommand(QStringLiteral("m -a \"my acct\" -t")), + QStringList({QStringLiteral("m"), QStringLiteral("-a"), + QStringLiteral("my acct"), QStringLiteral("-t")})); + QCOMPARE(QProcess::splitCommand(QStringLiteral("m -a 'my acct' -t")), + QStringList({QStringLiteral("m"), QStringLiteral("-a"), + QStringLiteral("'my"), QStringLiteral("acct'"), + QStringLiteral("-t")})); +} + +void TestMessageSender::nothingIsEverReportedTwice() +{ + // Reporting twice would close the send popup and then act on a second + // result, which for a caller that files a sent copy on success means two + // copies, or a success followed by a failure. Run every outcome through one + // sender and count. + const QString ok = writeStub(QStringLiteral("dup-ok.sh"), + QStringLiteral("cat >/dev/null")); + const QString bad = writeStub(QStringLiteral("dup-bad.sh"), + QStringLiteral("echo boom >&2; exit 1")); + QVERIFY(!ok.isEmpty() && !bad.isEmpty()); + + for (const QString &command : + {ok, bad, QStringLiteral("/nonexistent/msmtp")}) { + MessageSender sender; + QSignalSpy spy(&sender, &MessageSender::finished); + QVERIFY(sender.send(command, QByteArray(1600 * 1024, 'x'))); + QVERIFY(spy.wait(10000)); + // Give any second signal a chance to arrive before counting. + QTest::qWait(300); + QVERIFY2(spy.count() == 1, + qPrintable(QStringLiteral("%1 reported %2 times") + .arg(command) + .arg(spy.count()))); + } +} + +void TestMessageSender::destroyingTheSenderLetsAnInFlightSendFinish() +{ + // The composer's X button is reachable mid-send, and abandoning a live + // SMTP conversation has a genuinely unknown outcome. Measured before the + // destructor existed: plain destruction 100ms into a one-second command + // killed the child and the work did NOT complete, announced by nothing but + // a "QProcess: Destroyed while process is still running" warning. + // + // The marker file is the evidence, because it is written by the command + // itself after its work: if the destructor killed the child, it does not + // exist. + const QString marker = m_dir.filePath(QStringLiteral("send-completed")); + const QString stub = writeStub( + QStringLiteral("slowfinish.sh"), + QStringLiteral("cat >/dev/null; sleep 1; touch '%1'").arg(marker)); + QVERIFY(!stub.isEmpty()); + QVERIFY2(!QFile::exists(marker), "the marker existed before the send ran"); + + { + MessageSender sender; + QVERIFY(sender.send(stub, QByteArray("body"))); + // Destroyed well before the command could finish, which is the case + // that matters; without the wait this scope kills it. + QTest::qWait(100); + QVERIFY2(sender.isRunning(), "the command finished before it was abandoned"); + } + + QVERIFY2(QFile::exists(marker), + "destroying the sender killed a send that was in flight"); +} + +void TestMessageSender::destroyingTheSenderEmitsNothing() +{ + // After a kill the outcome is unknown, and this class reports two outcomes + // only. A finished(false, ...) from the destructor would report "not sent" + // for a message that may have been delivered, which is the mailsync.sh + // mistake pointing the other way. + // + // A command that outlasts the shutdown wait is what forces the kill + // branch, so the wait is shortened by pointing the test at a command + // longer than it rather than by changing the constant. + const QString stub = writeStub(QStringLiteral("outlast.sh"), + QStringLiteral("cat >/dev/null; sleep 30")); + QVERIFY(!stub.isEmpty()); + + QSignalSpy *spy = nullptr; + { + MessageSender sender; + spy = new QSignalSpy(&sender, &MessageSender::finished); + QVERIFY(sender.send(stub, QByteArray("body"))); + QTest::qWait(100); + QVERIFY(sender.isRunning()); + // The destructor runs as this scope ends: it waits kShutdownWaitMs + // for a command that will not finish, then kills it. + } + // The spy outlives the sender deliberately: a signal emitted during + // destruction would have been recorded before the object went away. + QCOMPARE(spy->count(), 0); + delete spy; +} + +void TestMessageSender::aPerSendConnectionMustBeSingleShot() +{ + // The header's contract, asserted. m_reported collapses two QProcess + // signals into one emit, but it cannot stop a caller from accumulating + // RECEIVERS: a long-lived sender that a caller connects to inside its send + // path runs every previous lambda on the next result, each still holding + // the previous message's bytes. + // + // This is the plan's own Task 11 shape, and it is why that step now + // specifies Qt::SingleShotConnection. + const QString stub = writeStub(QStringLiteral("twice.sh"), + QStringLiteral("cat >/dev/null")); + QVERIFY(!stub.isEmpty()); + + MessageSender sender; // long-lived, as a ComposeWindow member is + + // The broken shape: a bare connect() beside each send(). + int bareDeliveries = 0; + for (int i = 0; i < 2; ++i) { + QSignalSpy spy(&sender, &MessageSender::finished); + connect(&sender, &MessageSender::finished, this, + [&bareDeliveries](bool, const QString &) { ++bareDeliveries; }); + QVERIFY(sender.send(stub, QByteArray("body"))); + QVERIFY(spy.wait(5000)); + QCOMPARE(spy.count(), 1); // ONE emit, both times + } + QVERIFY2(bareDeliveries == 3, + qPrintable(QStringLiteral("expected the documented 1+2 accumulation, got %1") + .arg(bareDeliveries))); + + // The prescribed shape: the connection disconnects as it fires, so two + // sends deliver two results rather than three. + MessageSender clean; + int singleShotDeliveries = 0; + for (int i = 0; i < 2; ++i) { + QSignalSpy spy(&clean, &MessageSender::finished); + connect(&clean, &MessageSender::finished, this, + [&singleShotDeliveries](bool, const QString &) { ++singleShotDeliveries; }, + Qt::SingleShotConnection); + QVERIFY(clean.send(stub, QByteArray("body"))); + QVERIFY(spy.wait(5000)); + } + QCOMPARE(singleShotDeliveries, 2); +} + +QTEST_MAIN(TestMessageSender) +#include "test_messagesender.moc" diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index 76652b8..489c62d 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -998,6 +998,21 @@ Message-Id: + + MessageSender + + The send command crashed. + Il comando di invio si è arrestato in modo anomalo. + + + The send command exited with status %1 and said nothing. + Il comando di invio è terminato con stato %1 senza fornire spiegazioni. + + + The send command '%1' could not be started. Check that the path is correct and the file is executable. + Impossibile avviare il comando di invio '%1'. Verifica che il percorso sia corretto e che il file sia eseguibile. + + MessageView @@ -1254,6 +1269,18 @@ The message could not be assembled. Non è stato possibile comporre il messaggio. + + No folder was configured to write to. + Nessuna cartella configurata per la scrittura. + + + Cannot create the folder %1. + Impossibile creare la cartella %1. + + + Cannot write to %1: %2 + Impossibile scrivere su %1: %2 + QueryCompleter -- cgit v1.2.3 From 2b32350204dfb49089c465856464a043018ca3c6 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 21 Aug 2026 15:37:54 +0200 Subject: feat(compose): derive a reply's recipients and headers, item 123 ComposeContext, task 7 of the compose-and-send plan. Address parsing, recipient derivation, the References chain, subject prefixing and account resolution, as free functions over values so they test without a painter. Recipient derivation was designed from the spec rather than transcribed: the plan's draft omitted it and its tests could not compile, calling QVERIFY(config.load(path)) against a void return. Six defects found in review, each pinned by a test checked against the mutation that breaks it: - Message-ids reached GMime bare, and GMime writes an EMPTY header for a bare addr-spec rather than complaining. In-Reply-To and References both shipped blank, so every reply would have arrived as an orphan thread with nothing wrong to see locally. MessageBuilder now brackets on write, in the one place that composes those headers rather than in each caller. - internet_address_to_string was called with FALSE for the encode flag, so a display name carrying a raw newline rendered with the newline intact. That is a header-injection primitive. - A reply to the user's own message addressed the user. It now goes to that message's original recipients, mirroring their To/Cc split, which is what the Sent view and a follow-up on unanswered mail need. - A From parsing to no mailbox left To empty, reachable from real mail ("From: Mailer Daemon"). MessageBuilder treats an empty recipient list as success, so the message would have been handed to the send command with nobody to deliver to and filed in Sent looking sent. - The References header was split on whitespace alone, so a client's non-conformant "," became one token and the bracket strip produced the fabricated id "a@x>, Claude-Session: https://claude.ai/code/session_01LoaLBowZ6w1JNx6SEhDP1L --- .../specs/2026-08-20-compose-and-send-design.md | 39 +- src/CMakeLists.txt | 1 + src/composecontext.cpp | 517 ++++++++++ src/composecontext.h | 177 ++++ src/messagebuilder.cpp | 55 +- src/mimeparser.cpp | 2 + src/mimeparser.h | 16 + tests/CMakeLists.txt | 1 + tests/test_composecontext.cpp | 1051 ++++++++++++++++++++ tests/test_messagebuilder.cpp | 33 + 10 files changed, 1884 insertions(+), 8 deletions(-) create mode 100644 src/composecontext.cpp create mode 100644 src/composecontext.h create mode 100644 tests/test_composecontext.cpp (limited to 'tests/CMakeLists.txt') diff --git a/docs/superpowers/specs/2026-08-20-compose-and-send-design.md b/docs/superpowers/specs/2026-08-20-compose-and-send-design.md index aade2d1..9533602 100644 --- a/docs/superpowers/specs/2026-08-20-compose-and-send-design.md +++ b/docs/superpowers/specs/2026-08-20-compose-and-send-design.md @@ -430,7 +430,7 @@ Two structs cross boundaries, in `types.h` beside the existing ones. | `originalPath` | the `.eml` being replied to or forwarded; empty for New | | `inReplyTo` | Message-ID of the original | | `references` | the original's References plus its Message-ID | -| `to`, `cc` | pre-filled recipients, the user's own addresses already stripped | +| `to`, `cc` | pre-filled recipients, the user's own addresses already stripped; a reply to the user's OWN message is addressed to that message's recipients instead of back to the user, mirroring its To/Cc split (see Replying to oneself) | | `subject` | `Re:` / `Fwd:` prefixed, an existing prefix not doubled | | `quotedBody` | the `>`-prefixed original; empty when the action does not quote | | `seedHtml` | did the original carry a `text/html` part | @@ -447,6 +447,13 @@ Two structs cross boundaries, in `types.h` beside the existing ones. | `attachments` | local paths | | `inReplyTo`, `references` | carried through unchanged | +Message-ids are carried BARE, without angle brackets, matching what GMime hands +back when `MimeParser` reads a `Message-ID`. `MessageBuilder` adds the brackets +when it writes the header, in one place rather than in each caller: they are wire +syntax, and GMime writes an EMPTY header for a bare addr-spec rather than +complaining, so a caller that forgets them ships a reply that threads nowhere +while nothing looks wrong locally. + `In-Reply-To` and `References` are not optional. Without them a reply appears as an orphan thread in the sender's own client. @@ -505,15 +512,38 @@ Six, each needing the five places `CLAUDE.md` enumerates: `knownActions()`, | Action | Meaning | Scope | |---|---|---| | `compose` | New message | none needed | -| `reply` | Reply to the displayed message, quoted | sender only | +| `reply` | Reply to the displayed message, quoted | sender only, except when the sender is the user (see below) | | `reply_all` | Reply to all, quoted | sender + To + Cc, own addresses removed | -| `reply_no_quote` | Reply with an empty body | sender only | +| `reply_no_quote` | Reply with an empty body | sender only, same exception | | `forward` | Forward, body quoted inline, attachments carried | none | | `save_message` | Write the raw `.eml` to a chosen path | any message | `reply_all_no_quote` is deliberately absent. Six actions is already a large menu and the combination is reached by deleting the quote. +### Replying to oneself + +A reply whose sender is entirely the user's own addresses is addressed to that +message's **original recipients** rather than to the sender. A plain reply takes +its To and Cc together, having no Cc field of its own to mirror into. A +reply-all MIRRORS THE SPLIT: the original's To becomes To and its Cc becomes Cc, +because To means "addressed to you" and Cc "for information", and promoting a +Cc'd party to To is a change every recipient can see. +This is an ordinary gesture rather than an edge case: it is reached from the +Sent view, from a follow-up on mail that went unanswered, and from any thread +whose selected row is the user's own message. Addressing the sender there +addresses the user, so the reply reaches nobody it was meant for. + +"Own" means EVERY parsed sender address is the user's. A message the user sent +together with somebody else is still a reply to that co-sender, and takes the +ordinary sender-only path. + +Mail the user sent to THEMSELVES alone leaves nothing after own addresses are +removed, and there the sender is restored: the user is the correct recipient of +their own note. The rejected alternative was to strip the sender and leave To +empty, which silently drops every recipient while the message still looks +sendable. + **Every action acts on the displayed message**, resolved with `messageScopeFor()` semantics: a thread row means the one message its card shows, a reply row means itself. Not `threadFor()`. Replying to a thread is @@ -660,7 +690,8 @@ Cases: `multipart/alternative` when `sendHtml` is on and `text/plain` alone when off; `multipart/mixed` nesting with attachments; each enabled extension rendering, and tables and raw HTML **not** rendering; RFC 2047 encoding of a non-ASCII subject and display name; quoted-printable for an accented body; -`In-Reply-To` and `References` carried; `Re:` and `Fwd:` not doubling. +`In-Reply-To` and `References` carried; `Re:` and `Fwd:` not doubling, in the +non-English spellings and counted forms as well as the English ones. **`test_messagesender`** uses stub commands, not msmtp: one exiting 0, one exiting non-zero with stderr, one that does not exist. The stub writes stdin to diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index eac2fab..a462ba3 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -15,6 +15,7 @@ add_library(qtmaildir_lib STATIC maildirname.cpp draftstore.cpp messagesender.cpp + composecontext.cpp tagchip.cpp tagcolors.cpp savequerydialog.cpp diff --git a/src/composecontext.cpp b/src/composecontext.cpp new file mode 100644 index 0000000..251a028 --- /dev/null +++ b/src/composecontext.cpp @@ -0,0 +1,517 @@ +/* + * 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. + */ + +// gmime BEFORE any Qt header. glib declares a struct field named "signals", +// which Qt #defines to Q_SIGNALS, and the collision is a compile error whose +// message names neither library. +#include + +#include "composecontext.h" + +#include "config.h" +#include "mimeparser.h" + +#include +#include +#include + +namespace { + +/// GMime must be initialised exactly once per process. +/// +/// MimeParser and MessageBuilder each carry their own copy of this guard, and +/// this is a third rather than a shared one for the reason MessageBuilder +/// already records: a test may link only one of them, so neither can assume +/// another ran. Without it the first internet_address_list_parse() call +/// dereferences an uninitialised type registry and SEGVs, which is exactly what +/// this file did before the guard was added. +/// +/// A function-local static rather than the `static bool` flag the other two +/// use: C++11 guarantees the initialiser runs exactly once even under +/// concurrent entry, which a bare flag does not. +void ensureGMimeInitialised() +{ + static const bool initialised = [] { + g_mime_init(); + return true; + }(); + Q_UNUSED(initialised); +} + +/// Matches a reply prefix at the START of a subject, in the spellings clients +/// actually produce. +/// +/// Anchored, and that is load-bearing rather than tidy: an unanchored search +/// finds "re:" inside an ordinary subject ("Notes re: budget") and refuses to +/// prefix a genuine first reply, which breaks threading in the recipient's +/// client with nothing to see locally. +/// +/// **The non-English spellings are not politeness, they are the doubling bug in +/// a mixed-locale mailbox**, which this one is: the user writes Italian and +/// corresponds beyond it. German and Dutch clients send `AW:`, Scandinavian +/// ones `SV:`, Spanish and Portuguese `RES:`. An English-only pattern turns +/// every one of those into `Re: AW: subject`, and the next round into +/// `Re: Re: AW:`. +/// +/// `Re[2]:` and `Re(2):` are the counted forms Outlook and some list managers +/// emit. They mean the same thing and must not be doubled either. +/// +/// **Single-letter spellings are deliberately NOT here**, though Italian +/// clients do send `R:`. Measured 2026-08-21: with `r` in the alternation, +/// `R: report on Q3` reads as a reply prefix, so a genuine first reply to that +/// subject gets no `Re:` and threads nowhere in the recipient's client. A +/// one-letter token before a colon is an ordinary subject far more often than +/// it is a prefix, and this is the same failure the anchoring note above +/// describes. The cost of omitting it is one doubled `Re: R:`, which is +/// cosmetic; the cost of including it is broken threading, which is not. +/// +/// Ordering inside the alternation matters: `res` before `re` so the longer +/// spelling is not consumed by the shorter one, leaving a stray `S:` unmatched. +const QRegularExpression &replyPrefix() +{ + static const QRegularExpression expression( + QStringLiteral("^\\s*(res|re|aw|antw|sv|vs)\\s*(\\[\\d+\\]|\\(\\d+\\))?\\s*:"), + QRegularExpression::CaseInsensitiveOption); + return expression; +} + +/// "Fwd:" and "Fw:" mean the same thing and both are common, as do the +/// non-English spellings a mixed-locale mailbox receives: German `WG:`, Spanish +/// and Portuguese `RV:` and `ENC:`, French `TR:`. Same reasoning as +/// replyPrefix(): an English-only pattern produces `Fwd: WG: subject`. +/// +/// Italian `I:` is omitted for the reason replyPrefix() omits `R:`, and it is +/// the worse of the two: `I: notes` is an entirely ordinary subject. The +/// previous pattern was `fwd?`, which likewise matched a bare `F:`; that is not +/// a forward marker in any client and `F: results` must still get a prefix. +const QRegularExpression &forwardPrefix() +{ + static const QRegularExpression expression( + QStringLiteral("^\\s*(fwd|fw|wg|enc|rv|tr)\\s*(\\[\\d+\\]|\\(\\d+\\))?\\s*:"), + QRegularExpression::CaseInsensitiveOption); + return expression; +} + +/// The account whose maildir contains \p path, or empty. +QString accountOwning(const Config &config, const QString &path, + const QString &mailRoot) +{ + for (const Account &account : config.accounts()) { + if (account.maildir.isEmpty()) + continue; + const QString prefix = + QDir(mailRoot).absoluteFilePath(account.maildir) + QLatin1Char('/'); + // Compared as a path prefix with the separator INCLUDED: without the + // trailing slash an account "work" would also match a maildir + // "work-archive", and the reply would be sent from the wrong account. + if (path.startsWith(prefix)) + return account.key; + } + return {}; +} + +/// True when \p address is one of \p ownAddresses, compared case-insensitively. +/// +/// Compared on the addr-spec, never on a rendered "Name ": a display +/// name may legitimately contain an address-looking substring, and a substring +/// test against the whole form strips a real recipient whose name happens to +/// quote one of the user's addresses. +bool isOwn(const QString &address, const QStringList &ownAddresses) +{ + for (const QString &own : ownAddresses) { + if (own.isEmpty()) + continue; + if (address.compare(own, Qt::CaseInsensitive) == 0) + return true; + } + return false; +} + +/// Appends \p recipient to \p out unless its address is already in \p seen or +/// belongs to the user. \p seen is updated. +/// +/// Deduplication is keyed on the lowercased ADDRESS, so the same mailbox under +/// two different display names counts once, which is what the original's To +/// and Cc routinely contain. +void appendUnlessSuppressed(const ComposeContextBuilder::Recipient &recipient, + const QStringList &ownAddresses, + QSet *seen, QStringList *out) +{ + if (recipient.address.isEmpty()) + return; + const QString key = recipient.address.toLower(); + if (seen->contains(key)) + return; + if (isOwn(recipient.address, ownAddresses)) + return; + seen->insert(key); + out->append(recipient.rendered); +} + +} // namespace + +QList +ComposeContextBuilder::parseAddressHeader(const QString &rawHeader) +{ + ensureGMimeInitialised(); + + const QByteArray utf8 = rawHeader.trimmed().toUtf8(); + if (utf8.isEmpty()) + return {}; + + // Returns NULL rather than an empty list for input it can make nothing of, + // including the empty string. Guarded above and again here: the header is + // untrusted and this is the crash if it is not. + InternetAddressList *list = internet_address_list_parse(nullptr, utf8.constData()); + if (!list) + return {}; + + QList recipients; + const int count = internet_address_list_length(list); + for (int i = 0; i < count; ++i) { + InternetAddress *address = internet_address_list_get_address(list, i); + if (!address) + continue; + + // Only MAILBOXES. A group carries a name and no address, so keeping it + // would put "undisclosed-recipients" in a To field as though it were a + // person. It is also the injection defence: measured 2026-08-21, a raw + // newline smuggled into a header makes GMime parse the following + // "Bcc: evil@example.net" as a GROUP, and dropping non-mailboxes drops + // it rather than pre-filling a recipient the user never saw. + if (!INTERNET_ADDRESS_IS_MAILBOX(address)) + continue; + + const char *addr = + internet_address_mailbox_get_addr(INTERNET_ADDRESS_MAILBOX(address)); + if (!addr || !*addr) + continue; + + Recipient recipient; + recipient.address = QString::fromUtf8(addr).trimmed(); + if (recipient.address.isEmpty()) + continue; + + // Rendered BY GMIME rather than assembled by string. Quoting a display + // name is not a matter of wrapping it in quotes: a name containing a + // comma must come back out quoted or it re-parses as two recipients. + // + // The final argument is ENCODE, and it is a security parameter rather + // than a formatting preference. With FALSE a display name carrying a + // raw newline renders with that newline intact, which is a + // header-injection primitive: `"foo\nBcc: evil@example.net" ` + // comes back out verbatim and anything writing it into a To: line + // emits a second header the user never saw. With TRUE the same input + // renders RFC 2047 encoded as `=?iso-8859-1?q?foo=0ABcc=3A?= ...` and + // the newline can no longer terminate a header. Measured 2026-08-21; + // this shipped as FALSE and the test caught it. + char *rendered = internet_address_to_string( + address, g_mime_format_options_get_default(), TRUE); + recipient.rendered = rendered ? QString::fromUtf8(rendered).trimmed() + : QString(); + g_free(rendered); + if (recipient.rendered.isEmpty()) + recipient.rendered = recipient.address; + + recipients.append(recipient); + } + g_object_unref(list); + + return recipients; +} + +QStringList ComposeContextBuilder::ownAddresses(const Config &config) +{ + QStringList addresses; + for (const Account &account : config.accounts()) { + const QString address = account.address.trimmed(); + // An empty address is dropped rather than collected. It would match + // nothing usefully and, in any substring comparison, everything. + if (!address.isEmpty() && !addresses.contains(address, Qt::CaseInsensitive)) + addresses.append(address); + } + return addresses; +} + +void ComposeContextBuilder::recipientsForReply(const ParsedMessage &message, + bool replyAll, + const QStringList &ownAddresses, + QStringList *toOut, + QStringList *ccOut) +{ + if (toOut) + toOut->clear(); + if (ccOut) + ccOut->clear(); + if (!toOut) + return; + + // Reply-To wins over From when present (RFC 5322 3.6.2: it names where the + // author wants replies sent). Applied to reply-all as well as to a plain + // reply: a list's reply-all belongs on the list too. + QList sender = parseAddressHeader(message.replyTo); + if (sender.isEmpty()) + sender = parseAddressHeader(message.from); + + // A reply to the user's OWN message goes to the people that message was + // addressed to, not back to the user. Reached from the Sent view, from a + // follow-up on unanswered mail, and from any thread whose selected row is + // the user's own message, so it is an ordinary gesture rather than an edge + // case. The alternative considered and rejected was stripping the sender + // and leaving To empty, which silently drops every recipient and looks + // sendable. + // + // "Own" means EVERY parsed sender address is the user's. A message with a + // co-sender is still a reply to that co-sender. + bool senderIsSelf = !sender.isEmpty(); + for (const Recipient &recipient : sender) { + if (!isOwn(recipient.address, ownAddresses)) { + senderIsSelf = false; + break; + } + } + + QSet seen; + if (senderIsSelf) { + // The original's To, with own addresses removed. Its Cc is deliberately + // NOT taken here for a reply-all: the split is the message's meaning, + // To being "addressed to you" and Cc "for information", and promoting a + // Cc'd party to To is visible to every recipient. The Cc pass below + // carries them across unchanged, so the reply mirrors the original. + // + // A PLAIN reply has no Cc field to mirror into, so it takes To and Cc + // together: everyone who was on the message is still addressed, which + // is what a reply to a conversation the user started means. + // + // Mail the user sent to themselves alone leaves nothing after the own + // filter, which is the one case where addressing the user IS correct, + // so the sender is restored below rather than producing an empty To. + QStringList headers = { message.to }; + if (!replyAll) + headers.append(message.cc); + for (const QString &header : headers) { + const QList parsed = parseAddressHeader(header); + for (const Recipient &recipient : parsed) + appendUnlessSuppressed(recipient, ownAddresses, &seen, toOut); + } + } + + if (toOut->isEmpty()) { + for (const Recipient &recipient : sender) { + if (recipient.address.isEmpty()) + continue; + const QString key = recipient.address.toLower(); + if (seen.contains(key)) + continue; + // The sender is NOT filtered against the user's own addresses + // here. This branch is reached either for an ordinary reply, where + // the sender is somebody else, or for a note the user sent only to + // themselves, where they are the correct recipient. Stripping in + // either case produces a message with no recipient that still + // looks sendable. + seen.insert(key); + toOut->append(recipient.rendered); + } + } + + // A From that parses to no mailbox at all leaves To empty, and that is + // reachable from real mail rather than only from a hostile fixture: a bare + // display name with no angle brackets ("From: Mailer Daemon") is what + // bounces and some automated senders emit, and MimeParser hands it over as + // a header with zero mailboxes. An empty To is the worst outcome available, + // since MessageBuilder treats it as success: the message is handed to the + // send command with nobody to deliver to and a copy is filed in Sent that + // looks sent and reached no one. + // + // The original's recipients are the only remaining candidates. Own + // addresses are stripped, so a message the user sent AND that has an + // unparseable From still yields nothing here, which is correct: there is + // genuinely nobody to address, and the composer shows an empty To the user + // can see and fill rather than a wrong one they will not check. + if (toOut->isEmpty()) { + for (const QString &header : { message.to, message.cc }) { + const QList parsed = parseAddressHeader(header); + for (const Recipient &recipient : parsed) + appendUnlessSuppressed(recipient, ownAddresses, &seen, toOut); + } + } + + if (!replyAll || !ccOut) + return; + + // Everyone else goes to Cc, with the user's own addresses removed and + // duplicates suppressed ACROSS the two fields rather than within each: the + // sender is very often also in the original's To, and per-field + // deduplication lists them twice. + for (const QString &header : { message.to, message.cc }) { + const QList parsed = parseAddressHeader(header); + for (const Recipient &recipient : parsed) + appendUnlessSuppressed(recipient, ownAddresses, &seen, ccOut); + } +} + +QStringList ComposeContextBuilder::referencesForReply(const ParsedMessage &message) +{ + QStringList references; + QSet seen; + + const auto append = [&references, &seen](const QString &raw) { + QString id = raw.trimmed(); + if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>'))) + id = id.mid(1, id.size() - 2).trimmed(); + if (id.isEmpty() || seen.contains(id)) + return; + seen.insert(id); + references.append(id); + }; + + // The header is a whitespace-separated run of , and real mail + // wraps it across lines, so whitespace is the conformant separator. + // + // Commas are accepted BESIDES whitespace because some clients emit + // `,`, which RFC 5322 does not allow here. Splitting on + // whitespace alone turns that whole header into ONE token, and the bracket + // strip below then yields the garbage id `a@x>, sending = config.sendingAccounts(); + if (!sending.isEmpty()) + return sending.first().key; + + // No account can send. A valid read-only installation; the caller's action + // is disabled and should never have reached this. + return {}; +} + +QString ComposeContextBuilder::replySubject(const QString &original) +{ + if (replyPrefix().match(original).hasMatch()) + return original; + return QStringLiteral("Re: ") + original; +} + +QString ComposeContextBuilder::forwardSubject(const QString &original) +{ + if (forwardPrefix().match(original).hasMatch()) + return original; + return QStringLiteral("Fwd: ") + original; +} + +QString ComposeContextBuilder::quoteBody(const ParsedMessage &message) +{ + QStringList quoted; + + // The attribution line. Deliberately NOT translated and NOT reformatted + // through a locale-dependent date format: this text is sent to a recipient + // who may not share the user's locale, and the raw Date header is what + // every other client quotes. + quoted.append(QStringLiteral("On %1, %2 wrote:") + .arg(message.date, message.from)); + quoted.append(QString()); + + // Normalised to LF first. A CRLF body split on '\n' alone leaves a + // carriage return at the end of every line, which survives into the sent + // message as a stray CR in the middle of a quoted line. + QString body = message.plainBody; + body.replace(QStringLiteral("\r\n"), QStringLiteral("\n")); + body.replace(QLatin1Char('\r'), QLatin1Char('\n')); + + const QStringList lines = body.split(QLatin1Char('\n')); + for (const QString &line : lines) { + // A blank line still carries the marker. Without it the quote visually + // ends there in every client that renders quoting. + quoted.append(line.isEmpty() ? QStringLiteral(">") + : QStringLiteral("> ") + line); + } + + return quoted.join(QLatin1Char('\n')); +} diff --git a/src/composecontext.h b/src/composecontext.h new file mode 100644 index 0000000..4027af0 --- /dev/null +++ b/src/composecontext.h @@ -0,0 +1,177 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include + +#include "types.h" + +struct Account; +class Config; +struct ParsedMessage; + +/// Builds the ComposeContext that opens a composer. +/// +/// Free functions in a namespace: this is pure logic over values, and keeping +/// it apart from ComposeWindow is what lets recipient derivation, subject +/// prefixing and account resolution be tested without a painter. +namespace ComposeContextBuilder { + +/// One recipient split out of a header, as both parts and as a rendered whole. +/// +/// Kept as a struct rather than a bare string because the two halves answer +/// two different questions and conflating them is how the user's own address +/// escapes a filter. `address` is what a comparison must use: a display name +/// may legitimately CONTAIN an address-looking substring, and a substring test +/// against the whole rendered form matches "not-me@example.org" for "me@example.org". +/// `rendered` is what goes in the field the user sees. +struct Recipient +{ + QString address; ///< The bare addr-spec, no display name, no angle brackets. + QString rendered; ///< "Name " or the bare address when it has no name. +}; + +/// The addresses belonging to the user, across every configured account. +/// +/// Every one of them is stripped from a reply-all's recipients. Missing one +/// means the user receives their own reply, which is the failure this is most +/// likely to have. +QStringList ownAddresses(const Config &config); + +/// Splits a raw address header into individual recipients, using GMime. +/// +/// NEVER split on commas. A display name may contain one, so +/// `"Rossi, Mario" , info@example.net` is TWO addresses and a +/// naive split reports three, one of which ("Rossi") is not an address at all +/// and would be handed to the send command as a recipient. This is the same +/// reason `recipientSummary()` in mimeparser.cpp parses rather than splits. +/// +/// Groups (`undisclosed-recipients:;`) contribute NOTHING. A group carries a +/// name and no mailbox, so naming it would put "undisclosed-recipients" in a +/// To field as though it were a person. This also closes a header-injection +/// shape: a raw newline in a header value makes GMime parse the smuggled +/// `Bcc: evil@example.net` as a GROUP, measured 2026-08-21, so dropping +/// non-mailboxes drops the injected recipient rather than carrying it forward. +/// +/// An unparseable header yields an empty list rather than a partial guess. +QList parseAddressHeader(const QString &rawHeader); + +/// Who a reply goes to, as \p toOut and \p ccOut. +/// +/// This is the function the spec calls out as where the subtle bugs live, and +/// the rules are not interchangeable: +/// +/// - **Reply** goes to the ORIGINAL SENDER only, and Cc is empty. Reply-To +/// takes precedence over From when the original carries one (RFC 5322 +/// §3.6.2: it names where the author wants replies sent), which is what +/// makes a mailing list's reply land on the list rather than on a person who +/// never asked to be written to directly. +/// - **Reply-all** puts the sender in To, and the original's To and Cc in Cc. +/// The user's own addresses are stripped from BOTH, or they receive their +/// own reply. Comparison is case-insensitive: an address's domain is +/// case-insensitive by RFC and real mail varies the local part's case too, +/// so a case-sensitive filter lets `User@Example.org` through against a +/// configured `user@example.org`. +/// - A duplicate is suppressed ACROSS To and Cc, not within each: the sender +/// is very often also in the original's To, and listing them twice is what +/// naive per-field deduplication produces. +/// +/// \p replyAll false yields sender-only. \p ownAddresses is what +/// ownAddresses(config) returned. +/// +/// **A reply to the user's OWN message goes where that message went**, not +/// back to the user: To comes from the original's recipients instead of from +/// its sender. A plain reply takes its To and Cc together, having no Cc field +/// of its own to mirror into; a reply-all MIRRORS THE SPLIT, the original's To +/// becoming To and its Cc becoming Cc, because To means "addressed to you" and +/// Cc "for information" and promoting a Cc'd party to To is visible to every +/// recipient. This is reached from the Sent view, from a follow-up on +/// unanswered mail, and from any thread whose selected row is the user's own +/// message, so it is an ordinary gesture. "Own" means EVERY parsed sender +/// address is the user's; a co-sender is still someone to reply to. +/// +/// Mail the user sent to THEMSELVES alone leaves nothing after that filter, and +/// there the sender is restored: the user is the correct recipient of their own +/// note. Emptying To instead would produce a message with no recipient that +/// still looks sendable, which is why stripping the sender was rejected as the +/// fix. Nothing else strips an own address from a plain Reply's To. +void recipientsForReply(const ParsedMessage &message, bool replyAll, + const QStringList &ownAddresses, + QStringList *toOut, QStringList *ccOut); + +/// The References header for a reply: the original's References plus its +/// Message-ID. +/// +/// Not optional. Without it a reply appears as an orphan thread in the +/// sender's own client. A duplicate Message-ID is not appended twice. +/// +/// Ids come back BARE, without angle brackets, matching what GMime hands back +/// when MimeParser reads a `Message-ID`. The brackets are wire syntax and +/// `MessageBuilder` adds them when it writes the header, in one place rather +/// than in each caller: GMime writes an EMPTY header for a bare addr-spec +/// rather than complaining, so a caller that forgets them ships a reply that +/// threads nowhere while nothing looks wrong locally. +QStringList referencesForReply(const ParsedMessage &message); + +/// Which account replies to a message whose file lives at \p messagePaths. +/// +/// The displayed message's own maildir is the strongest available signal and +/// wins outright: mail sent to an address landed in that address's maildir, so +/// replying from it is what the recipient expects. The account dropdown is NOT +/// consulted. +/// +/// A message can be in more than one maildir: on a list twice under two +/// addresses, or duplicated across accounts by mbsync, and notmuch returns +/// several filenames for one id. \p recipients disambiguates by preferring the +/// account matching a To or Cc entry; failing that the first is taken. The From +/// field shows the choice, so an arbitrary resolution is visible rather than +/// hidden. +QString accountForReply(const Config &config, const QStringList &messagePaths, + const QStringList &recipients, const QString &mailRoot); + +/// Which account a NEW message comes from, by the four fallback rules. +/// +/// \p selectedAccount is the dropdown's current account, empty for All +/// accounts. Returns empty only when no account can send at all. +QString accountForNew(const Config &config, const QString &selectedAccount); + +/// `Re:` or `Fwd:` prefixed, without doubling an existing prefix. +/// +/// An existing prefix is recognised in the non-English spellings a mixed-locale +/// mailbox receives (`AW:`, `SV:`, `RES:`, `WG:`, `TR:`, `RV:`, `ENC:`) and in +/// the counted forms Outlook emits (`Re[2]:`, `Re(3):`), or every one of those +/// doubles into `Re: AW: subject`. +/// +/// Single-letter spellings are deliberately NOT recognised, though Italian +/// clients send `R:` and `I:`: `R: report on Q3` is an ordinary subject, and +/// treating it as a prefix means a genuine first reply gets no `Re:` and +/// threads nowhere. See the patterns in composecontext.cpp for the measurement. +QString replySubject(const QString &original); +QString forwardSubject(const QString &original); + +/// The `>`-prefixed original, with an attribution line. +/// +/// Takes a ParsedMessage, NOT a MessageNode: the node carries no body and no +/// date (it holds messageId, threadId, from, subject, tags, filePath and +/// depth), so quoting has to come from what MimeParser produced. +QString quoteBody(const ParsedMessage &message); + +} // namespace ComposeContextBuilder diff --git a/src/messagebuilder.cpp b/src/messagebuilder.cpp index f27c3c2..42a0e31 100644 --- a/src/messagebuilder.cpp +++ b/src/messagebuilder.cpp @@ -109,6 +109,35 @@ GMimePart *makeTextPart(const char *subtype, const QString &text) /// strict. `garbage` and `""` both parse to a one-entry list. This function /// rejects what GMime cannot parse at all; it is not an address validator, and /// a typo that happens to be parseable still goes out. +bool setAddressHeader(GMimeMessage *message, const char *header, const QStringList &addresses, + QString *badEntry); + +/// A message-id in the angle brackets the wire format requires, added if the +/// caller did not supply them. +/// +/// **The brackets are syntax, not decoration, and GMime enforces it by writing +/// an EMPTY HEADER for a bare addr-spec rather than by complaining.** Measured +/// 2026-08-21: `In-Reply-To: current@example.org` emits `In-Reply-To:` with no +/// value, so the reply arrives as an orphan thread in the recipient's client +/// while nothing looks wrong locally. +/// +/// Bracketing lives HERE, in the one function that composes these headers, +/// rather than in each caller. Every source of a message-id in this application +/// hands over a bare one: GMime strips the brackets when MimeParser reads +/// `Message-ID`, and `ComposeContextBuilder::referencesForReply` strips them +/// again from the References chain so the two agree. A convention spread across +/// callers is one a later caller gets wrong, and the failure is invisible +/// without inspecting a sent message. +QString bracketed(const QString &messageId) +{ + const QString id = messageId.trimmed(); + if (id.isEmpty()) + return {}; + if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>'))) + return id; + return QLatin1Char('<') + id + QLatin1Char('>'); +} + bool setAddressHeader(GMimeMessage *message, const char *header, const QStringList &addresses, QString *badEntry) { @@ -238,12 +267,30 @@ Result build(const OutgoingMessage &message, const Account &account) const QByteArray subject = message.subject.toUtf8(); g_mime_message_set_subject(mime, subject.constData(), "utf-8"); - if (!message.inReplyTo.trimmed().isEmpty()) { - const QByteArray value = message.inReplyTo.trimmed().toUtf8(); + // Both headers are bracketed HERE rather than by the caller. See bracketed() + // for why, and for what a bare id costs. + const QString inReplyTo = bracketed(message.inReplyTo); + if (!inReplyTo.isEmpty()) { + const QByteArray value = inReplyTo.toUtf8(); g_mime_object_set_header(GMIME_OBJECT(mime), "In-Reply-To", value.constData(), "utf-8"); } - if (!message.references.isEmpty()) { - const QByteArray value = message.references.join(QLatin1Char(' ')).toUtf8(); + QStringList references; + for (const QString &id : message.references) { + const QString bracketedId = bracketed(id); + // An empty entry contributes nothing rather than a stray "<>": the + // References header is a run of ids, and one malformed entry is enough + // for a strict parser to discard the whole chain. + // + // Defensive rather than a path with a fixture behind it, like the + // length check in setAddressHeader above: referencesForReply() already + // drops empty ids, so a mutation on this line SURVIVES the suite. + // Measured 2026-08-21. Kept because it costs one comparison and the + // failure it covers is a silently broken thread. + if (!bracketedId.isEmpty()) + references.append(bracketedId); + } + if (!references.isEmpty()) { + const QByteArray value = references.join(QLatin1Char(' ')).toUtf8(); g_mime_object_set_header(GMIME_OBJECT(mime), "References", value.constData(), "utf-8"); } diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp index 2782a4a..c1198b8 100644 --- a/src/mimeparser.cpp +++ b/src/mimeparser.cpp @@ -412,9 +412,11 @@ ParsedMessage MimeParser::parse(const QString &filePath) const out.subject = QString::fromUtf8( g_mime_message_get_subject(message) ?: ""); out.from = headerText(message, "From"); + out.replyTo = headerText(message, "Reply-To"); out.to = headerText(message, "To"); out.cc = headerText(message, "Cc"); out.date = headerText(message, "Date"); + out.references = headerText(message, "References"); out.messageId = QString::fromUtf8( g_mime_message_get_message_id(message) ?: ""); diff --git a/src/mimeparser.h b/src/mimeparser.h index da54434..64c4585 100644 --- a/src/mimeparser.h +++ b/src/mimeparser.h @@ -119,11 +119,27 @@ struct ParsedMessage QString subject; QString from; + + /// Where the author asked for replies to go, raw and undecoded-into-parts. + /// + /// Takes precedence over `from` when building a reply (RFC 5322 3.6.2). + /// Empty on the great majority of mail; a mailing list is the common case + /// that sets it, and honouring it is what keeps a list reply on the list + /// rather than on a person who never asked to be written to directly. + QString replyTo; + QString to; QString cc; QString date; QString messageId; + /// The raw References header, a whitespace-separated run of . + /// + /// Carried so a reply can extend the chain. Without it the reply appears + /// as an orphan thread in the recipient's client, which is the whole + /// reason the header exists. + QString references; + QString plainBody; QString htmlBody; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e38d764..48b30fc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -73,6 +73,7 @@ add_qtmaildir_test(messagebuilder) add_qtmaildir_test(maildirname) add_qtmaildir_test(draftstore) add_qtmaildir_test(messagesender) +add_qtmaildir_test(composecontext) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up diff --git a/tests/test_composecontext.cpp b/tests/test_composecontext.cpp new file mode 100644 index 0000000..fccec87 --- /dev/null +++ b/tests/test_composecontext.cpp @@ -0,0 +1,1051 @@ +/* + * 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. + */ + +// gmime BEFORE any Qt header. glib declares a struct field named "signals", +// which Qt #defines to Q_SIGNALS. Needed here for g_log_set_handler(), which is +// how aGroupIsDroppedByTheGuardAndNotByAFailedCast() sees the difference +// between a group dropped by the guard and one dropped by a failed cast. +#include + +#include +#include + +#include "composecontext.h" +#include "config.h" +#include "mimeparser.h" +#include "types.h" + +using ComposeContextBuilder::Recipient; + +class TestComposeContext : public QObject +{ + Q_OBJECT + +private slots: + // Own addresses. + void everyOwnAddressIsCollected(); + void aBlankOwnAddressIsNotCollected(); + + // Header parsing, the foundation the recipient rules stand on. + void aDisplayNameContainingACommaIsOneRecipient(); + void aQuotedDisplayNameSurvivesRoundTripping(); + void aMalformedHeaderYieldsNoRecipients(); + void anEmptyHeaderYieldsNoRecipients(); + void aGroupContributesNoRecipient(); + void aGroupIsDroppedByTheGuardAndNotByAFailedCast(); + void anInjectedHeaderLineIsNotCarriedForward(); + + // Reply and reply-all recipient derivation. + void aPlainReplyGoesToTheSenderOnly(); + void aReplyPrefersReplyToOverFrom(); + void aReplyAllPutsTheSenderInToAndTheRestInCc(); + void aReplyAllStripsEveryOwnAddress(); + void aReplyAllStripsAnOwnAddressRegardlessOfCase(); + void aReplyAllDoesNotListTheSenderTwice(); + void aReplyAllSuppressesDuplicatesAcrossToAndCc(); + void aReplyToOneselfStillAddressesSomeone(); + void aReplyToOwnMessageGoesToItsOriginalRecipients(); + void aReplyAllToOwnMessageDoesNotRepeatToInCc(); + void aCoSenderIsStillRepliedTo(); + void anUnparseableSenderStillProducesARecipient(); + void aReplyAllPrefersReplyToForTheToField(); + void aDisplayNameContainingAnOwnAddressIsNotMistakenForIt(); + + // References. + void referencesCarryTheOriginalChainPlusItsId(); + void referencesDoNotRepeatTheMessageId(); + void aCommaSeparatedReferencesHeaderIsSplitIntoIds(); + + // Subjects. + void aReplySubjectDoesNotDoubleItsPrefix(); + void aForwardSubjectDoesNotDoubleItsPrefix(); + void anEmptySubjectStillGetsAPrefix(); + void aSubjectMentioningReLaterStillGetsAPrefix(); + void aNonEnglishPrefixIsNotDoubled(); + void aCountedPrefixIsNotDoubled(); + void aSingleLetterBeforeAColonIsNotAPrefix(); + + // Account resolution. + void theReplyAccountComesFromTheMessagesMaildir(); + void anAccountIsNotMatchedByAPrefixOfItsMaildir(); + void anAmbiguousMessagePrefersTheMatchingRecipient(); + void anAmbiguousMessageWithNoMatchTakesTheFirst(); + void aNewMessagePrefersTheSelectedAccount(); + void aNewMessageFallsThroughASelectedAccountThatCannotSend(); + void aNewMessageUsesDefaultAccountFromAllAccounts(); + void aNewMessageUsesStartupAccountWhenNoDefaultIsSet(); + void aNewMessageFallsBackToTheFirstSendingAccount(); + void aNewMessageReturnsNothingWhenNoAccountCanSend(); + + // Quoting. + void aQuotedBodyPrefixesEveryLine(); + +private: + QString writeConfig(const QString &contents); + + QTemporaryDir m_dir; +}; + +QString TestComposeContext::writeConfig(const QString &contents) +{ + // A unique name per call: Config caches nothing, but reusing one path + // across tests in one binary invites a stale read to look like a pass. + static int counter = 0; + const QString path = + m_dir.filePath(QStringLiteral("qtmaildir%1.conf").arg(++counter)); + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) + return {}; + file.write(contents.toUtf8()); + file.close(); + return path; +} + +// --------------------------------------------------------------------------- +// Own addresses +// --------------------------------------------------------------------------- + +void TestComposeContext::everyOwnAddressIsCollected() +{ + // All five of the user's addresses. Missing one means they receive their + // own reply, and with five accounts that is the likeliest bug here. + const QString path = writeConfig(QStringLiteral( + "[account.one]\nmaildir=one\ntrash=Trash\naddress=first@example.org\n" + "[account.two]\nmaildir=two\ntrash=Trash\naddress=second@example.org\n" + "[account.three]\nmaildir=three\ntrash=Trash\naddress=third@example.org\n" + "[account.four]\nmaildir=four\ntrash=Trash\naddress=fourth@example.org\n" + "[account.five]\nmaildir=five\ntrash=Trash\naddress=fifth@example.org\n")); + QVERIFY(!path.isEmpty()); + + Config config; + config.load(path); + QCOMPARE(config.accounts().size(), 5); + + const QStringList own = ComposeContextBuilder::ownAddresses(config); + QCOMPARE(own.size(), 5); + for (const QString &address : { QStringLiteral("first@example.org"), + QStringLiteral("second@example.org"), + QStringLiteral("third@example.org"), + QStringLiteral("fourth@example.org"), + QStringLiteral("fifth@example.org") }) { + QVERIFY2(own.contains(address), + qPrintable(QStringLiteral("own address %1 was not collected").arg(address))); + } +} + +void TestComposeContext::aBlankOwnAddressIsNotCollected() +{ + // An account with no address key is legal. An empty string in this list + // would match nothing usefully and, in a substring filter, everything. + const QString path = writeConfig(QStringLiteral( + "[account.one]\nmaildir=one\ntrash=Trash\naddress=first@example.org\n" + "[account.noaddress]\nmaildir=two\ntrash=Trash\n")); + Config config; + config.load(path); + QCOMPARE(config.accounts().size(), 2); + + const QStringList own = ComposeContextBuilder::ownAddresses(config); + QCOMPARE(own, QStringList{ QStringLiteral("first@example.org") }); +} + +// --------------------------------------------------------------------------- +// Header parsing +// --------------------------------------------------------------------------- + +void TestComposeContext::aDisplayNameContainingACommaIsOneRecipient() +{ + // The single most likely parsing bug: splitting on commas turns one + // recipient into two, one of which ("Rossi") is not an address at all and + // would be handed to the send command. + const QList parsed = ComposeContextBuilder::parseAddressHeader( + QStringLiteral("\"Rossi, Mario\" , info@example.net")); + + QCOMPARE(parsed.size(), 2); + QCOMPARE(parsed.at(0).address, QStringLiteral("m@example.org")); + QCOMPARE(parsed.at(1).address, QStringLiteral("info@example.net")); +} + +void TestComposeContext::aQuotedDisplayNameSurvivesRoundTripping() +{ + // A comma in a display name must come back out QUOTED. Unquoted, the + // rendered form is not a legal single address: it happens to survive + // GMime's own lenient re-parse, but it goes into a To: header that other + // clients and MTAs read, and a bare comma there is a recipient separator. + // + // Asserted on the RENDERED TEXT rather than on a re-parse, and that is the + // point of the test: a round-trip through parseAddressHeader() passes + // against string-assembled "Rossi, Mario " because GMime + // reads it back as one address anyway. Measured 2026-08-21, a mutation + // replacing the GMime rendering with `name + " <" + addr + ">"` left the + // whole suite green until this assertion was written this way. + const QList parsed = ComposeContextBuilder::parseAddressHeader( + QStringLiteral("\"Rossi, Mario\" ")); + QCOMPARE(parsed.size(), 1); + QCOMPARE(parsed.at(0).rendered, + QStringLiteral("\"Rossi, Mario\" ")); + + // And it still re-parses to the same one address. + const QList again = + ComposeContextBuilder::parseAddressHeader(parsed.at(0).rendered); + QCOMPARE(again.size(), 1); + QCOMPARE(again.at(0).address, QStringLiteral("m@example.org")); +} + +void TestComposeContext::aMalformedHeaderYieldsNoRecipients() +{ + // GMime returns NULL rather than an empty list for input it can make + // nothing of. Measured 2026-08-21: "not an address at all" and "<<<>>>" + // both return NULL. + QVERIFY(ComposeContextBuilder::parseAddressHeader( + QStringLiteral("not an address at all")).isEmpty()); + QVERIFY(ComposeContextBuilder::parseAddressHeader( + QStringLiteral("<<<>>>")).isEmpty()); +} + +void TestComposeContext::anEmptyHeaderYieldsNoRecipients() +{ + QVERIFY(ComposeContextBuilder::parseAddressHeader(QString()).isEmpty()); + QVERIFY(ComposeContextBuilder::parseAddressHeader( + QStringLiteral(" ")).isEmpty()); +} + +void TestComposeContext::aGroupContributesNoRecipient() +{ + // A group has a name and no mailbox. Carrying its name forward would put + // "undisclosed-recipients" in a To field as though it were a person. + const QList parsed = ComposeContextBuilder::parseAddressHeader( + QStringLiteral("undisclosed-recipients:;")); + QVERIFY2(parsed.isEmpty(), + qPrintable(QStringLiteral("a group produced %1 recipient(s)") + .arg(parsed.size()))); +} + +void TestComposeContext::aGroupIsDroppedByTheGuardAndNotByAFailedCast() +{ + // The count alone cannot see this, which is why the guard survived a + // mutation until 2026-08-21. Removing the INTERNET_ADDRESS_IS_MAILBOX check + // still yields no recipients, because the invalid cast makes GMime's own + // assertion return NULL and the address is skipped one line later. The + // count is therefore right for the wrong reason, and the reason matters: an + // invalid GObject cast is undefined behaviour papered over by an assertion + // that G_DISABLE_CHECKS compiles out and that G_DEBUG=fatal-criticals turns + // into an abort. A security property must not rest on assertions staying + // enabled. + // + // So this asserts on the CRITICAL rather than on the count. glib routes it + // through the log handler installed here, and a clean parse emits none. + struct Captured + { + static void handler(const gchar *domain, GLogLevelFlags level, + const gchar *messageText, gpointer userData) + { + Q_UNUSED(domain); + Q_UNUSED(level); + auto *messages = static_cast(userData); + messages->append(QString::fromUtf8(messageText)); + } + }; + + // Registered per DOMAIN, and the domain is the trap: the two criticals this + // watches for carry "GLib-GObject" and "gmime", while a NULL domain + // registers only for the default one. A handler on nullptr alone catches + // NOTHING here and the test passes against the mutation, measured + // 2026-08-21. + QStringList criticals; + const auto levels = GLogLevelFlags(G_LOG_LEVEL_CRITICAL | G_LOG_LEVEL_WARNING + | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION); + QList handlerIds; + for (const char *domain : { "GLib-GObject", "gmime" }) + handlerIds.append(g_log_set_handler(domain, levels, &Captured::handler, &criticals)); + + // A group carrying MEMBERS, not the empty "undisclosed-recipients:;". The + // empty form has nothing to cast, so it cannot tell the two paths apart. + const QList parsed = ComposeContextBuilder::parseAddressHeader( + QStringLiteral("friends: a@example.org, b@example.net;")); + + int i = 0; + for (const char *domain : { "GLib-GObject", "gmime" }) + g_log_remove_handler(domain, handlerIds.at(i++)); + + QVERIFY2(parsed.isEmpty(), + qPrintable(QStringLiteral("a group with members produced %1 recipient(s)") + .arg(parsed.size()))); + QVERIFY2(criticals.isEmpty(), + qPrintable(QStringLiteral("GMime emitted %1 during the parse: %2") + .arg(criticals.size()) + .arg(criticals.join(QLatin1Char('|'))))); +} + +void TestComposeContext::anInjectedHeaderLineIsNotCarriedForward() +{ + // Header injection, from a stranger's message into the user's reply. + // Measured 2026-08-21: GMime parses the smuggled line as a GROUP named + // "Bcc", so dropping non-mailboxes drops it. If groups were kept, a reply + // would silently pre-fill a recipient the user never saw. + const QList parsed = ComposeContextBuilder::parseAddressHeader( + QStringLiteral("a@example.org\nBcc: evil@example.net")); + + QCOMPARE(parsed.size(), 1); + QCOMPARE(parsed.at(0).address, QStringLiteral("a@example.org")); + for (const Recipient &recipient : parsed) { + QVERIFY2(!recipient.rendered.contains(QStringLiteral("evil@example.net")), + qPrintable(QStringLiteral("injected address survived in: %1") + .arg(recipient.rendered))); + } + + // The other injection shape: the newline hidden INSIDE a quoted display + // name, where it does not split the header and so is not dropped as a + // group. It has to come back RFC 2047 encoded, never as a raw newline: a + // bare CR or LF in a rendered recipient is a header-injection primitive + // the moment anything writes it into a To: line. Rendering by hand rather + // than through GMime is what loses the encoding. + const QList inName = ComposeContextBuilder::parseAddressHeader( + QStringLiteral("\"foo\nBcc: evil@example.net\" ")); + QCOMPARE(inName.size(), 1); + QCOMPARE(inName.at(0).address, QStringLiteral("a@example.org")); + QVERIFY2(!inName.at(0).rendered.contains(QLatin1Char('\n')) + && !inName.at(0).rendered.contains(QLatin1Char('\r')), + qPrintable(QStringLiteral("a raw newline survived into a rendered " + "recipient: %1") + .arg(inName.at(0).rendered))); +} + +// --------------------------------------------------------------------------- +// Reply and reply-all +// --------------------------------------------------------------------------- + +void TestComposeContext::aPlainReplyGoesToTheSenderOnly() +{ + ParsedMessage message; + message.from = QStringLiteral("Sender "); + message.to = QStringLiteral("me@example.org, other@example.net"); + message.cc = QStringLiteral("third@example.com"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply( + message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc); + + QCOMPARE(to.size(), 1); + QVERIFY2(to.at(0).contains(QStringLiteral("sender@example.org")), + qPrintable(QStringLiteral("To was %1").arg(to.join(QLatin1Char('|'))))); + QVERIFY2(cc.isEmpty(), + qPrintable(QStringLiteral("a plain reply put %1 in Cc") + .arg(cc.join(QLatin1Char('|'))))); +} + +void TestComposeContext::aReplyPrefersReplyToOverFrom() +{ + // RFC 5322 3.6.2: Reply-To names where the author wants replies sent. This + // is what makes a list reply land on the list rather than on a person who + // never asked to be written to directly. + ParsedMessage message; + message.from = QStringLiteral("Sender "); + message.replyTo = QStringLiteral("List "); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/false, {}, &to, &cc); + + QCOMPARE(to.size(), 1); + QVERIFY2(to.at(0).contains(QStringLiteral("list@example.net")), + qPrintable(QStringLiteral("To was %1, expected the Reply-To") + .arg(to.join(QLatin1Char('|'))))); + QVERIFY2(!to.at(0).contains(QStringLiteral("sender@example.org")), + "From was used despite a Reply-To being present"); +} + +void TestComposeContext::aReplyAllPutsTheSenderInToAndTheRestInCc() +{ + ParsedMessage message; + message.from = QStringLiteral("Sender "); + message.to = QStringLiteral("first@example.net"); + message.cc = QStringLiteral("second@example.com"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc); + + QCOMPARE(to.size(), 1); + QVERIFY(to.at(0).contains(QStringLiteral("sender@example.org"))); + + QCOMPARE(cc.size(), 2); + QVERIFY2(cc.join(QLatin1Char('|')).contains(QStringLiteral("first@example.net")), + qPrintable(QStringLiteral("Cc was %1").arg(cc.join(QLatin1Char('|'))))); + QVERIFY2(cc.join(QLatin1Char('|')).contains(QStringLiteral("second@example.com")), + qPrintable(QStringLiteral("Cc was %1").arg(cc.join(QLatin1Char('|'))))); +} + +void TestComposeContext::aReplyAllStripsEveryOwnAddress() +{ + // Five accounts, and the user's address appears in the original's To under + // THREE of them. Stripping only the first is the exact failure this guards: + // the reply-all would then be addressed to the user twice over. + ParsedMessage message; + message.from = QStringLiteral("Sender "); + message.to = QStringLiteral( + "first@example.org, stranger@example.net, third@example.org"); + message.cc = QStringLiteral("fifth@example.org, another@example.com"); + + const QStringList own = { QStringLiteral("first@example.org"), + QStringLiteral("second@example.org"), + QStringLiteral("third@example.org"), + QStringLiteral("fourth@example.org"), + QStringLiteral("fifth@example.org") }; + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, own, &to, &cc); + + const QString all = (to + cc).join(QLatin1Char('|')); + for (const QString &address : own) { + QVERIFY2(!all.contains(address, Qt::CaseInsensitive), + qPrintable(QStringLiteral("own address %1 survived in: %2") + .arg(address, all))); + } + // And the strangers must all still be there: a filter that removed + // everything would pass the check above while producing an unsendable reply. + QVERIFY2(all.contains(QStringLiteral("stranger@example.net")), + qPrintable(QStringLiteral("a stranger was stripped too: %1").arg(all))); + QVERIFY2(all.contains(QStringLiteral("another@example.com")), + qPrintable(QStringLiteral("a stranger was stripped too: %1").arg(all))); + QVERIFY2(all.contains(QStringLiteral("sender@example.org")), + qPrintable(QStringLiteral("the sender was stripped: %1").arg(all))); +} + +void TestComposeContext::aReplyAllStripsAnOwnAddressRegardlessOfCase() +{ + // A domain is case-insensitive by RFC and real mail varies the local part's + // case too. A case-sensitive filter lets the user's own address through and + // they receive their own reply. + ParsedMessage message; + message.from = QStringLiteral("Sender "); + message.to = QStringLiteral("Me@Example.ORG, stranger@example.net"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply( + message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &to, &cc); + + const QString all = (to + cc).join(QLatin1Char('|')); + QVERIFY2(!all.contains(QStringLiteral("Me@Example.ORG"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("a differently-cased own address survived: %1") + .arg(all))); + QVERIFY(all.contains(QStringLiteral("stranger@example.net"))); +} + +void TestComposeContext::aReplyAllDoesNotListTheSenderTwice() +{ + // The sender is very often also in their own message's To (a list posting + // reflected back). Without cross-field suppression they appear in To AND Cc. + ParsedMessage message; + message.from = QStringLiteral("Sender "); + message.to = QStringLiteral("sender@example.org, stranger@example.net"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc); + + const QString all = (to + cc).join(QLatin1Char('|')); + QCOMPARE(all.count(QStringLiteral("sender@example.org")), 1); + QVERIFY(all.contains(QStringLiteral("stranger@example.net"))); +} + +void TestComposeContext::aReplyAllSuppressesDuplicatesAcrossToAndCc() +{ + // The same address in the original's To and Cc, with different display + // names so a whole-string comparison would treat them as distinct. + ParsedMessage message; + message.from = QStringLiteral("Sender "); + message.to = QStringLiteral("Person One "); + message.cc = QStringLiteral("P. One , other@example.com"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc); + + const QString all = (to + cc).join(QLatin1Char('|')); + QCOMPARE(all.count(QStringLiteral("dup@example.net")), 1); + QVERIFY(all.contains(QStringLiteral("other@example.com"))); +} + +void TestComposeContext::aReplyToOneselfStillAddressesSomeone() +{ + // Replying to a message the user sent themselves. Stripping own addresses + // from a plain Reply's To would leave a message with no recipient that + // still looks sendable. + ParsedMessage message; + message.from = QStringLiteral("Me "); + message.to = QStringLiteral("me@example.org"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply( + message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc); + + QVERIFY2(!to.isEmpty(), "a reply to oneself produced no recipient at all"); + QVERIFY(to.join(QLatin1Char('|')).contains(QStringLiteral("me@example.org"))); +} + +void TestComposeContext::aReplyToOwnMessageGoesToItsOriginalRecipients() +{ + // The Sent view, and a follow-up on unanswered mail: the user replies to a + // message they sent. Addressing the sender there addresses the user, so To + // comes from the original's own recipients instead. The Cc entry is + // included because a reply to a conversation the user started belongs to + // everyone who was on it. + ParsedMessage message; + message.from = QStringLiteral("Me "); + message.to = QStringLiteral("Correspondent "); + message.cc = QStringLiteral("watcher@example.com"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply( + message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc); + + const QString joined = to.join(QLatin1Char('|')); + QVERIFY2(joined.contains(QStringLiteral("them@example.net")), + qPrintable(QStringLiteral("To was %1").arg(joined))); + QVERIFY2(joined.contains(QStringLiteral("watcher@example.com")), + qPrintable(QStringLiteral("To was %1").arg(joined))); + // The whole point: the user is not written back to themselves. + QVERIFY2(!joined.contains(QStringLiteral("me@example.org")), + qPrintable(QStringLiteral("the reply addressed the user: %1").arg(joined))); + QVERIFY2(cc.isEmpty(), "a plain reply produced a Cc"); +} + +void TestComposeContext::aReplyAllToOwnMessageDoesNotRepeatToInCc() +{ + // Reply-all to your own message MIRRORS the original's split: its To + // becomes To, its Cc becomes Cc. The split is the message's meaning, To + // being "addressed to you" and Cc "for information", and promoting a Cc'd + // party to To is visible to every recipient. + // + // Asserted per FIELD, not on the union. A test counting each address once + // across to + cc passes whether the split is preserved or collapsed, which + // is how the collapse shipped and survived its first mutation check. + ParsedMessage message; + message.from = QStringLiteral("Me "); + message.to = QStringLiteral("them@example.net"); + message.cc = QStringLiteral("watcher@example.com"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply( + message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &to, &cc); + + const QString toJoined = to.join(QLatin1Char('|')); + const QString ccJoined = cc.join(QLatin1Char('|')); + QVERIFY2(toJoined.contains(QStringLiteral("them@example.net")), + qPrintable(QStringLiteral("To was %1").arg(toJoined))); + QVERIFY2(!toJoined.contains(QStringLiteral("watcher@example.com")), + qPrintable(QStringLiteral("a Cc recipient was promoted to To: %1").arg(toJoined))); + QVERIFY2(ccJoined.contains(QStringLiteral("watcher@example.com")), + qPrintable(QStringLiteral("Cc was %1").arg(ccJoined))); + QVERIFY2(!ccJoined.contains(QStringLiteral("them@example.net")), + qPrintable(QStringLiteral("the To address repeated in Cc: %1").arg(ccJoined))); + // The whole point of the self-reply rule. + QVERIFY2(!(toJoined + ccJoined).contains(QStringLiteral("me@example.org")), + "the reply addressed the user"); +} + +void TestComposeContext::aCoSenderIsStillRepliedTo() +{ + // A message the user sent WITH somebody else is not a message to oneself. + // Only an all-own sender diverts To to the original recipients; here the + // co-sender is a real person expecting the reply. + ParsedMessage message; + message.from = QStringLiteral("Me , Other "); + message.to = QStringLiteral("them@example.com"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply( + message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc); + + const QString joined = to.join(QLatin1Char('|')); + QVERIFY2(joined.contains(QStringLiteral("other@example.net")), + qPrintable(QStringLiteral("To was %1").arg(joined))); + QVERIFY2(!joined.contains(QStringLiteral("them@example.com")), + qPrintable(QStringLiteral("a plain reply reached the original's To: %1") + .arg(joined))); +} + +void TestComposeContext::anUnparseableSenderStillProducesARecipient() +{ + // "From: Mailer Daemon" is a bare display name with no angle brackets, which + // is what bounces and some automated senders emit. It parses to ZERO + // mailboxes, so the sender contributes nothing and To would otherwise come + // out empty. + // + // An empty To is the worst outcome available here, because MessageBuilder + // treats an empty recipient list as success: the message reaches the send + // command with nobody to deliver to and a copy is filed in Sent that looks + // sent and reached no one. The original's own recipients are the remaining + // candidates. + ParsedMessage message; + message.from = QStringLiteral("Mailer Daemon"); + message.to = QStringLiteral("them@example.net"); + message.cc = QStringLiteral("watcher@example.com"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply( + message, /*replyAll=*/false, { QStringLiteral("me@example.org") }, &to, &cc); + + QVERIFY2(!to.isEmpty(), "an unparseable sender produced a reply with no recipient"); + QVERIFY2(to.join(QLatin1Char('|')).contains(QStringLiteral("them@example.net")), + qPrintable(QStringLiteral("To was %1").arg(to.join(QLatin1Char('|'))))); + + // Reply-all is the worse half: without the fallback it puts every recipient + // in Cc and leaves To empty, which is a message addressed to nobody. + QStringList allTo; + QStringList allCc; + ComposeContextBuilder::recipientsForReply( + message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &allTo, &allCc); + QVERIFY2(!allTo.isEmpty(), "a reply-all to an unparseable sender left To empty"); +} + +void TestComposeContext::aReplyAllPrefersReplyToForTheToField() +{ + // Reply-To precedence is not a plain-Reply-only rule: a list's reply-all + // must also go to the list rather than to the individual poster. + ParsedMessage message; + message.from = QStringLiteral("Poster "); + message.replyTo = QStringLiteral("List "); + message.to = QStringLiteral("list@example.net"); + message.cc = QStringLiteral("watcher@example.com"); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply(message, /*replyAll=*/true, {}, &to, &cc); + + QCOMPARE(to.size(), 1); + QVERIFY2(to.at(0).contains(QStringLiteral("list@example.net")), + qPrintable(QStringLiteral("To was %1").arg(to.join(QLatin1Char('|'))))); + // The list is in To, so it must not repeat in Cc even though the original's + // To named it. + QVERIFY2(!cc.join(QLatin1Char('|')).contains(QStringLiteral("list@example.net")), + qPrintable(QStringLiteral("the To address repeated in Cc: %1") + .arg(cc.join(QLatin1Char('|'))))); + QVERIFY(cc.join(QLatin1Char('|')).contains(QStringLiteral("watcher@example.com"))); +} + +void TestComposeContext::aDisplayNameContainingAnOwnAddressIsNotMistakenForIt() +{ + // A stranger whose DISPLAY NAME quotes the user's address. Comparing the + // rendered whole rather than the addr-spec would strip a real recipient, + // and the reply would silently not reach them. + ParsedMessage message; + message.from = QStringLiteral("Sender "); + message.to = QStringLiteral("\"about me@example.org\" "); + + QStringList to; + QStringList cc; + ComposeContextBuilder::recipientsForReply( + message, /*replyAll=*/true, { QStringLiteral("me@example.org") }, &to, &cc); + + QVERIFY2((to + cc).join(QLatin1Char('|')).contains(QStringLiteral("stranger@example.net")), + "a stranger was stripped because their display name quoted an own address"); +} + +// --------------------------------------------------------------------------- +// References +// --------------------------------------------------------------------------- + +void TestComposeContext::referencesCarryTheOriginalChainPlusItsId() +{ + ParsedMessage message; + message.messageId = QStringLiteral("current@example.org"); + message.references = + QStringLiteral(" "); + + const QStringList refs = ComposeContextBuilder::referencesForReply(message); + + QCOMPARE(refs.size(), 3); + QCOMPARE(refs.at(0), QStringLiteral("first@example.org")); + QCOMPARE(refs.at(1), QStringLiteral("second@example.org")); + QCOMPARE(refs.at(2), QStringLiteral("current@example.org")); +} + +void TestComposeContext::referencesDoNotRepeatTheMessageId() +{ + ParsedMessage message; + message.messageId = QStringLiteral("current@example.org"); + message.references = QStringLiteral(" "); + + const QStringList refs = ComposeContextBuilder::referencesForReply(message); + + QCOMPARE(refs.count(QStringLiteral("current@example.org")), 1); + QCOMPARE(refs.last(), QStringLiteral("current@example.org")); +} + +// --------------------------------------------------------------------------- +// Subjects +// --------------------------------------------------------------------------- + +void TestComposeContext::aCommaSeparatedReferencesHeaderIsSplitIntoIds() +{ + // `,` is not conformant, RFC 5322 has no comma here, but some + // clients emit it. Splitting on whitespace alone makes that whole header ONE + // token, and stripping its outer brackets then yields the fabricated id + // `a@x>,,"); + message.messageId = QStringLiteral("current@example.org"); + + const QStringList refs = ComposeContextBuilder::referencesForReply(message); + + QCOMPARE(refs.size(), 3); + QCOMPARE(refs.at(0), QStringLiteral("first@example.org")); + QCOMPARE(refs.at(1), QStringLiteral("second@example.org")); + QCOMPARE(refs.at(2), QStringLiteral("current@example.org")); +} + +void TestComposeContext::aReplySubjectDoesNotDoubleItsPrefix() +{ + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Hello")), + QStringLiteral("Re: Hello")); + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Re: Hello")), + QStringLiteral("Re: Hello")); + // Case and spacing vary between clients and neither justifies a second + // prefix. "RE:" from Outlook is the common one. + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("RE: Hello")), + QStringLiteral("RE: Hello")); + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("re:Hello")), + QStringLiteral("re:Hello")); +} + +void TestComposeContext::aForwardSubjectDoesNotDoubleItsPrefix() +{ + QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Hello")), + QStringLiteral("Fwd: Hello")); + QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Fwd: Hello")), + QStringLiteral("Fwd: Hello")); + // "Fw:" is the other common spelling and means the same thing. + QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Fw: Hello")), + QStringLiteral("Fw: Hello")); +} + +void TestComposeContext::anEmptySubjectStillGetsAPrefix() +{ + // A reply to a subjectless message is still a reply. "Re: " alone is + // correct and is what every other client produces. + QCOMPARE(ComposeContextBuilder::replySubject(QString()), + QStringLiteral("Re: ")); +} + +void TestComposeContext::aSubjectMentioningReLaterStillGetsAPrefix() +{ + // The prefix test is ANCHORED. An unanchored search would see "re:" inside + // an ordinary subject and refuse to prefix a genuine first reply, which + // breaks threading in the recipient's client. + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Notes re: budget")), + QStringLiteral("Re: Notes re: budget")); + QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("Notes fwd: budget")), + QStringLiteral("Fwd: Notes fwd: budget")); +} + +void TestComposeContext::aNonEnglishPrefixIsNotDoubled() +{ + // A mixed-locale mailbox, which this one is. An English-only pattern turns + // every one of these into "Re: AW: subject", and the round after that into + // "Re: Re: AW:". + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("AW: Angebot")), + QStringLiteral("AW: Angebot")); + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("SV: innkalling")), + QStringLiteral("SV: innkalling")); + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("RES: pedido")), + QStringLiteral("RES: pedido")); + QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("WG: Angebot")), + QStringLiteral("WG: Angebot")); + QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("TR: document")), + QStringLiteral("TR: document")); +} + +void TestComposeContext::aCountedPrefixIsNotDoubled() +{ + // Outlook and some list managers count the rounds. Same meaning, and + // prefixing again produces "Re: Re[2]:". + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Re[2]: thread")), + QStringLiteral("Re[2]: thread")); + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("Re(3): thread")), + QStringLiteral("Re(3): thread")); +} + +void TestComposeContext::aSingleLetterBeforeAColonIsNotAPrefix() +{ + // Italian clients do send "R:" and "I:", and they are deliberately NOT + // recognised. Measured 2026-08-21: with them in the pattern, "R: report on + // Q3" reads as an existing prefix, so a genuine FIRST reply gets no "Re:" + // and threads nowhere in the recipient's client, with nothing wrong to see + // locally. A doubled "Re: R:" is cosmetic; broken threading is not. + // + // "F:" is here for the same reason: the pattern was once `fwd?`, which + // matched it. + QCOMPARE(ComposeContextBuilder::replySubject(QStringLiteral("R: report on Q3")), + QStringLiteral("Re: R: report on Q3")); + QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("I: notes")), + QStringLiteral("Fwd: I: notes")); + QCOMPARE(ComposeContextBuilder::forwardSubject(QStringLiteral("F: results")), + QStringLiteral("Fwd: F: results")); +} + +// --------------------------------------------------------------------------- +// Account resolution +// --------------------------------------------------------------------------- + +void TestComposeContext::theReplyAccountComesFromTheMessagesMaildir() +{ + // The dropdown is NOT consulted: replying from the All accounts view to a + // message that arrived at account B sends from B. + const QString path = writeConfig(QStringLiteral( + "[account.work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n" + "send_command=/bin/true\n" + "[account.home]\nmaildir=home\ntrash=Trash\naddress=home@example.org\n" + "send_command=/bin/true\n")); + QVERIFY(!path.isEmpty()); + + Config config; + config.load(path); + QCOMPARE(config.accounts().size(), 2); + + const QString account = ComposeContextBuilder::accountForReply( + config, { QStringLiteral("/mail/home/INBOX/cur/123") }, + { QStringLiteral("home@example.org") }, QStringLiteral("/mail")); + + QCOMPARE(account, QStringLiteral("home")); +} + +void TestComposeContext::anAccountIsNotMatchedByAPrefixOfItsMaildir() +{ + // "work" must not claim a message living in "work-archive". Without the + // separator in the comparison it does, and the reply is sent from the + // wrong account. + // + // The account KEYS are chosen so the wrong answer is reached FIRST. + // Config builds its list from QSettings::childGroups(), which returns + // groups ALPHABETICALLY rather than in file order, so the section order + // here decides nothing and only the keys do. With "archive" before "work" + // the loop happens upon the correct account before it can mismatch, and + // the test passes against the bug: measured, a mutation dropping the + // separator left the suite fully green. "a-work" (maildir "work") sorts + // before "b-archive" (maildir "work-archive") and puts the prefix + // candidate first, where a textual comparison matches it. + const QString path = writeConfig(QStringLiteral( + "[account.a-work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n" + "send_command=/bin/true\n" + "[account.b-archive]\nmaildir=work-archive\ntrash=Trash\n" + "address=archive@example.org\nsend_command=/bin/true\n")); + Config config; + config.load(path); + QCOMPARE(config.accounts().size(), 2); + // The ordering the mutation depends on, asserted rather than assumed: if + // Config ever sorts differently this test silently stops testing anything. + QCOMPARE(config.accounts().at(0).key, QStringLiteral("a-work")); + + const QString account = ComposeContextBuilder::accountForReply( + config, { QStringLiteral("/mail/work-archive/INBOX/cur/1") }, + {}, QStringLiteral("/mail")); + + QCOMPARE(account, QStringLiteral("b-archive")); +} + +void TestComposeContext::anAmbiguousMessagePrefersTheMatchingRecipient() +{ + // One message, two maildirs: on a list twice under two addresses. The + // recipient headers are the tiebreak. + const QString path = writeConfig(QStringLiteral( + "[account.work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n" + "send_command=/bin/true\n" + "[account.home]\nmaildir=home\ntrash=Trash\naddress=home@example.org\n" + "send_command=/bin/true\n")); + QVERIFY(!path.isEmpty()); + + Config config; + config.load(path); + + const QString account = ComposeContextBuilder::accountForReply( + config, + { QStringLiteral("/mail/work/Lists/cur/1"), + QStringLiteral("/mail/home/Lists/cur/1") }, + { QStringLiteral("home@example.org") }, QStringLiteral("/mail")); + + QCOMPARE(account, QStringLiteral("home")); +} + +void TestComposeContext::anAmbiguousMessageWithNoMatchTakesTheFirst() +{ + // Arbitrary, and deliberately so: the From field shows the choice, which + // makes an arbitrary resolution visible rather than hidden. + const QString path = writeConfig(QStringLiteral( + "[account.work]\nmaildir=work\ntrash=Trash\naddress=work@example.org\n" + "send_command=/bin/true\n" + "[account.home]\nmaildir=home\ntrash=Trash\naddress=home@example.org\n" + "send_command=/bin/true\n")); + Config config; + config.load(path); + + const QString account = ComposeContextBuilder::accountForReply( + config, + { QStringLiteral("/mail/work/Lists/cur/1"), + QStringLiteral("/mail/home/Lists/cur/1") }, + { QStringLiteral("someone-else@example.org") }, QStringLiteral("/mail")); + + QVERIFY2(!account.isEmpty(), "an ambiguous message resolved to no account"); + QCOMPARE(account, QStringLiteral("work")); +} + +void TestComposeContext::aNewMessagePrefersTheSelectedAccount() +{ + const QString path = writeConfig(QStringLiteral( + "[account.work]\nmaildir=work\ntrash=Trash\nsend_command=/bin/true\n" + "[account.home]\nmaildir=home\ntrash=Trash\nsend_command=/bin/true\n")); + Config config; + config.load(path); + QCOMPARE(config.accounts().size(), 2); + + QCOMPARE(ComposeContextBuilder::accountForNew(config, QStringLiteral("home")), + QStringLiteral("home")); +} + +void TestComposeContext::aNewMessageFallsThroughASelectedAccountThatCannotSend() +{ + // Rule 1 requires the selected account CAN send. Viewing a receive-only + // account and pressing compose must produce a working composer from + // another account, not a broken one from this. + const QString path = writeConfig(QStringLiteral( + "[account.listsonly]\nmaildir=listsonly\ntrash=Trash\n" + "[account.work]\nmaildir=work\ntrash=Trash\nsend_command=/bin/true\n")); + Config config; + config.load(path); + QCOMPARE(config.accounts().size(), 2); + + QCOMPARE(ComposeContextBuilder::accountForNew(config, QStringLiteral("listsonly")), + QStringLiteral("work")); +} + +void TestComposeContext::aNewMessageUsesDefaultAccountFromAllAccounts() +{ + // The All accounts view has no selected account and falls through to rule 2. + // + // The named account must NOT also be what rule 4 would answer, or the test + // passes with rule 2 deleted outright: measured, a mutation removing it + // left the suite green because the account list is ALPHABETICAL (Config + // builds it from QSettings::childGroups()) and the section order in this + // string decides nothing. "zeta" sorts last, so rule 4 would answer + // "alpha" and only rule 2 can produce "zeta". + const QString path = writeConfig(QStringLiteral( + "[account.alpha]\nmaildir=alpha\ntrash=Trash\nsend_command=/bin/true\n" + "[account.zeta]\nmaildir=zeta\ntrash=Trash\nsend_command=/bin/true\n" + "[compose]\ndefault_account=zeta\n")); + Config config; + config.load(path); + QCOMPARE(config.compose().defaultAccount, QStringLiteral("zeta")); + // Asserted rather than assumed, so the test stops silently proving nothing + // if Config ever changes its ordering. + QCOMPARE(config.sendingAccounts().first().key, QStringLiteral("alpha")); + + QCOMPARE(ComposeContextBuilder::accountForNew(config, QString()), + QStringLiteral("zeta")); +} + +void TestComposeContext::aNewMessageUsesStartupAccountWhenNoDefaultIsSet() +{ + // Rule 3. Same ordering trap as rule 2: "zeta" must not be what rule 4 + // would answer, or a test for this rule passes with the rule deleted. + const QString path = writeConfig(QStringLiteral( + "[general]\nstartup_account=zeta\n" + "[account.alpha]\nmaildir=alpha\ntrash=Trash\nsend_command=/bin/true\n" + "[account.zeta]\nmaildir=zeta\ntrash=Trash\nsend_command=/bin/true\n")); + Config config; + config.load(path); + QCOMPARE(config.startupAccount(), QStringLiteral("zeta")); + QVERIFY(config.compose().defaultAccount.isEmpty()); + QCOMPARE(config.sendingAccounts().first().key, QStringLiteral("alpha")); + + QCOMPARE(ComposeContextBuilder::accountForNew(config, QString()), + QStringLiteral("zeta")); +} + +void TestComposeContext::aNewMessageFallsBackToTheFirstSendingAccount() +{ + // Rule 4, arbitrary, and the reason rules 2 and 3 exist. The receive-only + // account is FIRST, so "the first account" and "the first sending account" + // are different answers and the test distinguishes them. + const QString path = writeConfig(QStringLiteral( + "[account.listsonly]\nmaildir=listsonly\ntrash=Trash\n" + "[account.work]\nmaildir=work\ntrash=Trash\nsend_command=/bin/true\n")); + Config config; + config.load(path); + QCOMPARE(config.accounts().size(), 2); + + QCOMPARE(ComposeContextBuilder::accountForNew(config, QString()), + QStringLiteral("work")); +} + +void TestComposeContext::aNewMessageReturnsNothingWhenNoAccountCanSend() +{ + // A valid read-only installation. The compose action is disabled, so this + // should be unreachable, and returning empty rather than a random account + // is what makes a mistake visible instead of silent. + const QString path = writeConfig(QStringLiteral( + "[account.listsonly]\nmaildir=listsonly\ntrash=Trash\n")); + Config config; + config.load(path); + QCOMPARE(config.accounts().size(), 1); + + QVERIFY(ComposeContextBuilder::accountForNew(config, QString()).isEmpty()); +} + +// --------------------------------------------------------------------------- +// Quoting +// --------------------------------------------------------------------------- + +void TestComposeContext::aQuotedBodyPrefixesEveryLine() +{ + ParsedMessage message; + message.from = QStringLiteral("Sender "); + message.date = QStringLiteral("Thu, 20 Aug 2026 10:00:00 +0200"); + message.plainBody = QStringLiteral("first line\nsecond line\n\nafter a blank"); + + const QString quoted = ComposeContextBuilder::quoteBody(message); + + QVERIFY2(quoted.contains(QStringLiteral("> first line")), + qPrintable(QStringLiteral("first line not quoted:\n%1").arg(quoted))); + QVERIFY2(quoted.contains(QStringLiteral("> second line")), + "second line not quoted"); + // A blank line inside a quote must still carry the marker, or the quote + // visually ends there in every client that renders it. + QVERIFY2(quoted.contains(QStringLiteral("\n>\n")), + qPrintable(QStringLiteral("a blank line lost its marker:\n%1").arg(quoted))); + QVERIFY2(quoted.contains(QStringLiteral("sender@example.org")), + "no attribution line naming the sender"); + // A CRLF body must not leave a stray carriage return before every marker. + ParsedMessage crlf; + crlf.plainBody = QStringLiteral("one\r\ntwo"); + const QString quotedCrlf = ComposeContextBuilder::quoteBody(crlf); + QVERIFY2(!quotedCrlf.contains(QLatin1Char('\r')), + qPrintable(QStringLiteral("a carriage return survived quoting: %1") + .arg(quotedCrlf))); +} + +QTEST_MAIN(TestComposeContext) +#include "test_composecontext.moc" diff --git a/tests/test_messagebuilder.cpp b/tests/test_messagebuilder.cpp index 1f94784..73d388c 100644 --- a/tests/test_messagebuilder.cpp +++ b/tests/test_messagebuilder.cpp @@ -49,6 +49,7 @@ private slots: void anAccentedBodyIsUtf8QuotedPrintable(); void anAccentedSubjectIsRfc2047Utf8(); void inReplyToAndReferencesAreCarried(); + void bareMessageIdsAreBracketedRatherThanEmittedEmpty(); void attachmentsProduceMultipartMixed(); void aMissingAttachmentFailsTheBuild(); void aDirectoryAttachmentFailsRatherThanHangingTheProcess(); @@ -218,6 +219,38 @@ void TestMessageBuilder::inReplyToAndReferencesAreCarried() QVERIFY2(text.contains(QStringLiteral("")), qPrintable(text)); } +/// **The brackets are syntax, and a bare id ships an EMPTY header rather than a +/// malformed one.** This is what every real caller supplies: GMime strips the +/// brackets when MimeParser reads Message-ID, and +/// ComposeContextBuilder::referencesForReply strips them from the References +/// chain so the two agree, so both values arrive here bare. +/// +/// Measured 2026-08-21: handed `orig@example.org`, GMime wrote `In-Reply-To:` +/// with no value at all and did not complain. Every reply would have arrived as +/// an orphan thread in the recipient's client, with nothing wrong to see +/// locally. Asserted on the FULL header line, since a test for the id alone +/// passes against an empty header that merely contains the name. +void TestMessageBuilder::bareMessageIdsAreBracketedRatherThanEmittedEmpty() +{ + OutgoingMessage m = baseMessage(); + m.inReplyTo = QStringLiteral("orig@example.org"); + m.references = QStringList{QStringLiteral("older@example.org"), + QStringLiteral("orig@example.org")}; + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY2(text.contains(QStringLiteral("In-Reply-To: ")), qPrintable(text)); + QVERIFY2(text.contains( + QStringLiteral("References: ")), + qPrintable(text)); + // The failure this exists for: the header present and empty. + QVERIFY2(!text.contains(QStringLiteral("In-Reply-To:\r\n")) + && !text.contains(QStringLiteral("In-Reply-To:\n")), + qPrintable(text)); +} + /// The attachment wrapper must NEST the body, not sit beside it: multipart/mixed /// outermost, with the multipart/alternative as its first part. Beside it, a /// client would show the alternatives as attachments and the body would be -- cgit v1.2.3 From 95ae5dfe2df7858ad957b353dc0ae1d7af3b4832 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 21 Aug 2026 16:13:28 +0200 Subject: feat(compose): transform the markdown buffer for the toolbar, item 123 MarkdownFormat, task 8 of the compose-and-send plan. Three free functions over (text, selection start, selection end) returning the new text and the selection that follows it, so the grammar is tested without a widget. Three gaps in the plan's draft, each now pinned by a test checked against the mutation that breaks it: - QString::lastIndexOf INCLUDES the position it is given, so quoting with the cursor at the end of a line found that line's newline and quoted the FOLLOWING one. The draft's fixtures never placed a cursor there. - A backwards selection was normalised but never tested, so the swap was unguarded; a right-to-left drag is an ordinary gesture and Qt reports the anchor after the cursor. normalise() now swaps and clamps in one place. - A blank line inside a quoted range produced "> " with trailing whitespace, which editors and mail clients strip anyway. It is written bare. Two further defects came out of review: - quote()'s selectionStart was unasserted for any block not starting at line zero. Hardcoding it to 0 passed all nineteen tests, because the one test naming the property quoted the first line, where right and wrong coincide. A wrong selection there means a second press quotes a line the user never selected, and a following Bold bolds the wrong text. - A selection splitting a surrogate pair split the character across the inserted tokens, leaving invalid UTF-16. Not reachable from the toolbar, where arrow keys and mouse hit-testing both move in whole clusters, but reachable by any code computing a position arithmetically. normalise() nudges off a low surrogate; a collapsed cursor moves back on both ends, since widening would turn "insert an empty pair here" into "wrap the emoji". The buttons stack rather than toggle: a second Bold press gives ****this****, and a second Quote press nests. That is what the spec specifies, and the preserved selection exists so a second press can apply a SECOND token. A toggle was built during this task at the user's request and reverted on finding it contradicts the spec at two sites; it is recorded as backlog item 135, where the unanswered question is what replaces bold-then-italic. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LoaLBowZ6w1JNx6SEhDP1L --- .../plans/2026-08-03-post-0.1.0-usability.md | 73 +++++ src/CMakeLists.txt | 1 + src/formattoolbar.cpp | 182 +++++++++++ src/formattoolbar.h | 76 +++++ tests/CMakeLists.txt | 1 + tests/test_formattoolbar.cpp | 346 +++++++++++++++++++++ 6 files changed, 679 insertions(+) create mode 100644 src/formattoolbar.cpp create mode 100644 src/formattoolbar.h create mode 100644 tests/test_formattoolbar.cpp (limited to 'tests/CMakeLists.txt') diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index 01be5d0..e9f99a4 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -204,6 +204,7 @@ taking that too literally. | 132 | Every action must have a shortcut, and that no longer serves | policy | S | done, 2026-08-20. `everyActionHasAShortcut` is deleted and nothing replaces it: `everyActionIsReachableFromAMenu()` is the required rule and a shortcut is now a chosen subset. Nothing else needed changing, since `showShortcutReference()` already printed `(unbound)` for an empty sequence. Verified by unbinding `tag_rules` and running the suite green, which would have failed before | | 133 | The composer shows no markdown syntax highlighting | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `QSyntaxHighlighter` over the composer's editor, so `**bold**` reads as bold while the buffer stays plain markdown. Standard Qt, no dependency. Deliberately after 123's formatting toolbar: agreeing with the grammar about nesting and about code spans suppressing what is inside them is the expensive part, and the toolbar is what makes the feature usable | | 134 | The busy indicator is built inline and is about to be built twice | maintenance | S | done, 2026-08-20, af902e0. `BusyIndicator` (`src/busyindicator.h`) carries both modes: `MainWindow` uses the indeterminate one, and item 123's send popup takes the determinate half for its undo countdown, switching the same widget over when the command starts. Only the BAR was extracted, not the status label this row paired with it. `m_statusLabel` has 34 uses across `MainWindow` for transient messages, selection counts and sync phases, so it belongs to the window rather than to the indicator, and the send popup owns its own phase text | +| 135 | The formatting toolbar's buttons stack rather than toggle | v2 | S | open, 2026-08-21, asked for by the user during item 123 task 8 and reverted the same session. **A spec change, not a defect**: it conflicts with spec:236 ("deliberately no live toggle") and spec:187-190. Both sites need amending FIRST, and the amendment must resolve what replaces bold-then-italic, which is the gesture spec:187's preserved selection exists to serve and which a toggle makes unreachable. That question is the work; the state machine is understood and written up in the section | Sizes are rough: XS under an hour, S a sitting, M a session. @@ -1098,6 +1099,78 @@ Then Delete a message. Verified by hand on 2026-08-20; this is how it was found. **Size: S.** +## 135. The formatting toolbar's buttons stack rather than toggle + +**Observed (user, 2026-08-21):** pressing Bold a second time on already-bold +text adds another pair of asterisks rather than removing the first, so +`**this**` becomes `****this****`. Quote nests the same way: a second press on +`> one` gives `> > one`. The user asked for both to toggle. + +**A toggle was built and reverted the same session**, and the reason matters +more than the code: it was not unwanted, it **conflicts with the spec**, which +was not checked before the work started. + +- `2026-08-20-compose-and-send-design.md:236` states there is "deliberately no + live toggle that inserts and removes the quote while editing". +- `:187-190` is the complete statement of the wrap behaviour and describes only + wrapping, with no toggle anywhere. + +**Cause.** This is a **spec change, not a defect**, and both sites need +amending before any code is written again. + +Underneath sits a real design question the spec answers one way and a toggle +answers the other, which is why the two cannot simply coexist. `:187` preserves +the selection after a wrap **so that a second press applies a SECOND token** to +the same words: bold, then italic, without touching the mouse. A toggle makes +that gesture unreachable, because the second press now removes the first token +instead. **What replaces bold-then-italic is unanswered**, and answering it is +the substance of this item, not the state machine below. Possible directions, +none chosen: a modifier on the second press, a separate un-format action, or +accepting that the chord is lost and reaching nested emphasis by typing. + +**Approach.** When it is picked up, the transformation half is already +understood, so the notes below exist to stop it being rediscovered. A toggling +`wrap()` must distinguish three states, and a single "it unwraps" test passes +against most of them being broken: + +- **INSIDE** the tokens: `**this**` with `this` selected (2..6). The tokens sit + just outside the selection; the same characters stay selected afterwards. +- **AROUND** them: `**this**` selected whole (0..8). The selection shrinks to + the text that was between them. +- **PARTIALLY overlapping** one: `*this**` (6..13). Neither of the above. It + does not describe a wrapped span, and stripping would have to guess which + half of a token to keep, so wrapping is the predictable answer. + +**INSIDE must be checked before AROUND.** On `***this***` both tests match, and +only INSIDE removes the level the user actually asked for. + +**A naive adjacency test is wrong, and looks right.** Checking only whether the +characters either side of the selection equal the token means pressing *Italic* +on `**this**` finds a `*` on each side, strips one asterisk per side, and +**un-bolds text the user asked to italicise**. A strip must require the adjacent +RUN of token characters to be the token exactly, or the token plus one other +complete emphasis token: `***` is bold+italic and divisible either way, while a +run of two is one indivisible token whose half is not a token at all. This was +found by writing the italic-on-bold test, not by reading the code. + +The quote side is simpler but has one trap: a bare `>` is what the quote path +writes for a blank line, so an unquote that only recognises `"> "` leaves a +stray marker on every blank line in a round trip. Whether a mixed block (some +lines quoted, some not) quotes or unquotes is a decision; quoting it, so one +press makes the block uniform and the next unquotes it, avoids the button doing +two opposite things to two halves of one selection. + +**Constraints.** The spec amendment comes first and must resolve the +bold-then-italic question, or the same conflict recurs. `MarkdownFormat` is +painter-free and widget-free, so the whole state machine is unit-testable +without the composer; keep it that way. The toolbar shortcuts belong to the +composer window and do not touch `KeyMap`, so nothing here interacts with item +132. Note that toggling changes what the preserved selection is FOR, so +`wrappingTwiceNestsTheTokensAroundTheSameWords` and +`quotingAnAlreadyQuotedLineNestsIt` in `tests/test_formattoolbar.cpp` both +assert the current spec behaviour and would be replaced rather than extended. + + ## Deferred, unsized, or split out Items noted while triaging but not part of the original list. Same numbering diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a462ba3..501c276 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -16,6 +16,7 @@ add_library(qtmaildir_lib STATIC draftstore.cpp messagesender.cpp composecontext.cpp + formattoolbar.cpp tagchip.cpp tagcolors.cpp savequerydialog.cpp diff --git a/src/formattoolbar.cpp b/src/formattoolbar.cpp new file mode 100644 index 0000000..565d4af --- /dev/null +++ b/src/formattoolbar.cpp @@ -0,0 +1,182 @@ +/* + * 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 "formattoolbar.h" + +#include + + +namespace { + +/// Normalises the selection a widget reports into an ordered, in-range pair +/// that does not split a character. +/// +/// Three hazards, handled once rather than per function. A backwards drag +/// reports the anchor AFTER the cursor; a selection can outlive the edit that +/// shortened the buffer under it; and a boundary can land in the middle of a +/// surrogate pair, where inserting a token splits one character into two +/// halves and the result is not valid UTF-16 at all. +/// +/// The surrogate case is not reachable with an arrow key or the mouse, which +/// move in whole clusters, but QTextCursor::setPosition accepts such a +/// position, so any caller computing one arithmetically can produce it: a +/// draft restore, a find/replace, a template insertion. A boundary sitting on +/// a LOW surrogate is inside a pair, and moving it back by one puts it before +/// the whole character. +/// +/// A COLLAPSED cursor moves back, not outward: nudging the two ends in +/// opposite directions would turn an empty selection into a two-unit one and +/// wrap a character the user never selected. A real selection widens, so that +/// touching any part of a character covers the whole of it. +void normalise(const QString &text, int &from, int &to) +{ + from = qBound(0, from, int(text.size())); + to = qBound(0, to, int(text.size())); + if (from > to) + qSwap(from, to); + + const auto insidePair = [&text](int at) { + return at < text.size() && text.at(at).isLowSurrogate(); + }; + + if (from == to) { + if (insidePair(from)) { + --from; + to = from; + } + return; + } + + if (insidePair(from)) + --from; + if (insidePair(to)) + ++to; +} + +} // namespace + +MarkdownFormat::Edit MarkdownFormat::wrap(const QString &text, int start, + int end, const QString &token) +{ + Edit edit; + int from = start; + int to = end; + normalise(text, from, to); + + edit.text = text; + // The closing token first: inserting at `from` would shift `to`. + edit.text.insert(to, token); + edit.text.insert(from, token); + + if (from == to) { + // No selection: the cursor goes BETWEEN the two tokens so typing + // continues inside them. Landing after the closing token instead is + // the mistake a user notices on the first keystroke. + edit.selectionStart = from + token.size(); + edit.selectionEnd = edit.selectionStart; + } else { + // The selection is preserved so a second press applies a second token + // to the same words without reselecting: bold then italic. + edit.selectionStart = from + token.size(); + edit.selectionEnd = to + token.size(); + } + + return edit; +} + +MarkdownFormat::Edit MarkdownFormat::link(const QString &text, int start, int end) +{ + Edit edit; + int from = start; + int to = end; + normalise(text, from, to); + + const QString label = text.mid(from, to - from); + + edit.text = text; + edit.text.replace(from, to - from, QStringLiteral("[%1]()").arg(label)); + + if (label.isEmpty()) { + // Nothing selected: the label is what gets typed first, so the cursor + // goes inside the brackets, one past the '['. + edit.selectionStart = from + 1; + } else { + // The label is written; the URL is what remains, so the cursor goes + // inside the parentheses: past '[', the label, ']' and '('. + edit.selectionStart = from + label.size() + 3; + } + edit.selectionEnd = edit.selectionStart; + + return edit; +} + +MarkdownFormat::Edit MarkdownFormat::quote(const QString &text, int start, int end) +{ + Edit edit; + int from = start; + int to = end; + normalise(text, from, to); + + // Line-based, not a wrap. The selection is widened to whole lines first: + // quoting half a line produces markdown that means something else. + // + // The backwards search starts at `from - 1`, not at `from`. QString's + // lastIndexOf INCLUDES the position it is given, so a cursor sitting at + // the end of a line, immediately before its newline, would find that + // newline and quote the FOLLOWING line instead of the one the cursor is + // on. The guard against a negative position matters too, since -1 means + // "search from the end" and would find the last newline in the buffer. + const int firstLineStart = + from > 0 ? text.lastIndexOf(QLatin1Char('\n'), from - 1) + 1 : 0; + + // No newline after the last line, so the end of the text is the end of + // the block. Without this the whole tail would be dropped. + int lastLineEnd = text.indexOf(QLatin1Char('\n'), to); + if (lastLineEnd < 0) + lastLineEnd = text.size(); + + const QString before = text.left(firstLineStart); + const QString middle = text.mid(firstLineStart, lastLineEnd - firstLineStart); + const QString after = text.mid(lastLineEnd); + + const QStringList lines = middle.split(QLatin1Char('\n')); + + // Nesting rather than toggling, per the spec: a second press deepens the + // quote. There is deliberately no live toggle here, because tracking "my + // text" and "the quote" as separate pieces to make one reversible is + // machinery for a case the user answers by closing the composer. + QStringList result; + result.reserve(lines.size()); + for (const QString &line : lines) { + // A blank line keeps the marker, since that is what continues a quote + // block in markdown, but WITHOUT the trailing space: several editors + // and mail clients strip trailing whitespace, and stripping it from + // "> " leaves ">" anyway, so writing it bare is the same result + // reached deliberately. + result.append(line.isEmpty() ? QStringLiteral(">") + : QStringLiteral("> ") + line); + } + + const QString replacement = result.join(QLatin1Char('\n')); + edit.text = before + replacement + after; + // The quoted block stays selected, so a second press nests it. + edit.selectionStart = firstLineStart; + edit.selectionEnd = firstLineStart + replacement.size(); + + return edit; +} diff --git a/src/formattoolbar.h b/src/formattoolbar.h new file mode 100644 index 0000000..d820a88 --- /dev/null +++ b/src/formattoolbar.h @@ -0,0 +1,76 @@ +/* + * 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. + */ + +#pragma once + +#include + +/// The markdown transformations behind the composer's formatting toolbar. +/// +/// Free functions over text and a selection, with no widget anywhere, so the +/// grammar is tested without a painter. Each one is a transformation over the +/// SOURCE: nothing about the buffer changes, it stays markdown the user can +/// also type by hand. +/// +/// Every function takes the selection as the widget reports it, which means +/// the anchor may sit AFTER the cursor. Each one normalises with qMin/qMax +/// rather than requiring the caller to, since a backwards drag is an ordinary +/// gesture and a caller that forgets would corrupt the buffer silently. +/// Out-of-range positions are clamped to the text, so a stale selection +/// cannot index past the end, and a boundary landing INSIDE a surrogate pair +/// is nudged off it, so a position computed arithmetically cannot split a +/// character in half. +/// +/// Neither wrap() nor quote() TOGGLES. A second press stacks another level: +/// `**this**` becomes `***this***` and `> one` becomes `> > one`. That is the +/// design, not an omission. The selection is preserved precisely so a second +/// press can apply a SECOND token to the same words, bold then italic without +/// reselecting, and a toggle would make that gesture unreachable. A toggle is +/// wanted eventually and is a spec change rather than a fix; see item 135 in +/// the backlog for the states it has to distinguish. +namespace MarkdownFormat { + +/// The result of a transformation: the new text and where the selection +/// should end up. +struct Edit +{ + QString text; + int selectionStart = 0; + int selectionEnd = 0; +}; + +/// Wraps the selection in \p token, or inserts an empty pair with the cursor +/// BETWEEN the tokens when there is no selection. +/// +/// The cursor landing between the tokens is the property a user notices +/// immediately when it is wrong, and it is invisible to a test that only +/// compares the resulting text. +Edit wrap(const QString &text, int start, int end, const QString &token); + +/// `[text](url)`. With a selection the selected text becomes the label and +/// the cursor lands inside the empty parentheses, which is where the user has +/// to type next. With none the cursor lands inside the brackets, since the +/// label is then what gets typed first. +Edit link(const QString &text, int start, int end); + +/// `> ` on every line the selection touches, including a line the selection +/// only starts or ends on. Line-based rather than a wrap, so it cannot be +/// expressed with wrap(). +Edit quote(const QString &text, int start, int end); + +} // namespace MarkdownFormat diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 48b30fc..b28bf6f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,6 +74,7 @@ add_qtmaildir_test(maildirname) add_qtmaildir_test(draftstore) add_qtmaildir_test(messagesender) add_qtmaildir_test(composecontext) +add_qtmaildir_test(formattoolbar) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up diff --git a/tests/test_formattoolbar.cpp b/tests/test_formattoolbar.cpp new file mode 100644 index 0000000..36918f9 --- /dev/null +++ b/tests/test_formattoolbar.cpp @@ -0,0 +1,346 @@ +/* + * 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 "formattoolbar.h" + +class TestFormatToolbar : public QObject +{ + Q_OBJECT + +private slots: + void wrappingASelectionKeepsItSelected(); + void wrappingWithNoSelectionPutsTheCursorBetweenTheTokens(); + void wrappingAppliesTheTokenOnBothSides(); + void aBackwardsSelectionWrapsTheSameWordsAsAForwardOne(); + + void wrappingTwiceNestsTheTokensAroundTheSameWords(); + + void aLinkWithASelectionUsesItAsTheLabel(); + void aLinkWithNoSelectionLeavesTheCursorInTheLabel(); + void aBackwardsSelectionLinksTheSameWordsAsAForwardOne(); + void quotingPrefixesEveryLineTheSelectionTouches(); + void quotingAPartialLineStillQuotesTheWholeLine(); + void quotingASingleLineWithNoSelectionQuotesThatLine(); + void quotingWithTheCursorAtTheEndOfALineQuotesThatLineNotTheNext(); + void quotingSelectsTheQuotedLines(); + void quotingSelectsOnlyTheLineItQuoted(); + void quotingTheLastLineKeepsTheRestOfTheText(); + void quotingAnAlreadyQuotedLineNestsIt(); + void quotingAnEmptyLineLeavesTheMarkerWithoutTrailingSpace(); + void aSelectionPastTheEndIsClamped(); + void aSelectionSplittingASurrogatePairKeepsTheCharacterWhole(); +}; + +void TestFormatToolbar::wrappingASelectionKeepsItSelected() +{ + // The selection is preserved so a second button press applies a second + // token to the same words: bold then italic, without reselecting. + const MarkdownFormat::Edit edit = MarkdownFormat::wrap( + QStringLiteral("make this bold"), 5, 9, QStringLiteral("**")); + + QCOMPARE(edit.text, QStringLiteral("make **this** bold")); + QCOMPARE(edit.text.mid(edit.selectionStart, + edit.selectionEnd - edit.selectionStart), + QStringLiteral("this")); +} + +void TestFormatToolbar::wrappingWithNoSelectionPutsTheCursorBetweenTheTokens() +{ + // The property a user notices immediately when it is wrong: press Bold, + // start typing, and the words must appear INSIDE the asterisks. A text + // comparison alone passes whether the cursor is inside or after. + const MarkdownFormat::Edit edit = MarkdownFormat::wrap( + QStringLiteral("ab"), 2, 2, QStringLiteral("**")); + + QCOMPARE(edit.text, QStringLiteral("ab****")); + QCOMPARE(edit.selectionStart, edit.selectionEnd); + QCOMPARE(edit.selectionStart, 4); + + // Stated as the behaviour rather than the index: typing "x" here must + // produce "ab**x**". + QString typed = edit.text; + typed.insert(edit.selectionStart, QStringLiteral("x")); + QCOMPARE(typed, QStringLiteral("ab**x**")); +} + +void TestFormatToolbar::wrappingAppliesTheTokenOnBothSides() +{ + QCOMPARE(MarkdownFormat::wrap(QStringLiteral("x"), 0, 1, + QStringLiteral("~~")).text, + QStringLiteral("~~x~~")); + QCOMPARE(MarkdownFormat::wrap(QStringLiteral("x"), 0, 1, + QStringLiteral("`")).text, + QStringLiteral("`x`")); +} + +void TestFormatToolbar::aBackwardsSelectionWrapsTheSameWordsAsAForwardOne() +{ + // A drag from right to left reports the anchor after the cursor. Qt hands + // that over as-is, so a transformation that trusts the order inserts the + // closing token before the opening one and corrupts the buffer. + const MarkdownFormat::Edit edit = MarkdownFormat::wrap( + QStringLiteral("make this bold"), 9, 5, QStringLiteral("**")); + + QCOMPARE(edit.text, QStringLiteral("make **this** bold")); + QCOMPARE(edit.text.mid(edit.selectionStart, + edit.selectionEnd - edit.selectionStart), + QStringLiteral("this")); +} + +void TestFormatToolbar::wrappingTwiceNestsTheTokensAroundTheSameWords() +{ + // The reason the selection is preserved at all: bold, then italic, + // without touching the mouse. Asserting on the second result is what + // makes the preserved selection load-bearing rather than decorative, + // since a wrong selection here produces valid-looking but wrong markdown + // ("make ***this** bold*" or similar). + // + // Stacking rather than toggling is the spec's behaviour, not an + // omission: a second Bold press gives "****this****". A toggle is wanted + // eventually and would make THIS gesture unreachable, which is the + // unanswered design question recorded as backlog item 135. + const MarkdownFormat::Edit first = MarkdownFormat::wrap( + QStringLiteral("make this bold"), 5, 9, QStringLiteral("**")); + const MarkdownFormat::Edit second = MarkdownFormat::wrap( + first.text, first.selectionStart, first.selectionEnd, + QStringLiteral("*")); + + QCOMPARE(second.text, QStringLiteral("make ***this*** bold")); + QCOMPARE(second.text.mid(second.selectionStart, + second.selectionEnd - second.selectionStart), + QStringLiteral("this")); +} + +void TestFormatToolbar::aLinkWithASelectionUsesItAsTheLabel() +{ + const MarkdownFormat::Edit edit = MarkdownFormat::link( + QStringLiteral("see the docs"), 8, 12); + + QCOMPARE(edit.text, QStringLiteral("see the [docs]()")); + + // The cursor goes inside the parentheses: the label is written and the + // URL is what the user still has to type. + QCOMPARE(edit.selectionStart, edit.selectionEnd); + QString typed = edit.text; + typed.insert(edit.selectionStart, QStringLiteral("https://example.org")); + QCOMPARE(typed, QStringLiteral("see the [docs](https://example.org)")); +} + +void TestFormatToolbar::aLinkWithNoSelectionLeavesTheCursorInTheLabel() +{ + // With nothing selected there is no label yet, so the label is what the + // user types first. + const MarkdownFormat::Edit edit = MarkdownFormat::link(QString(), 0, 0); + + QCOMPARE(edit.text, QStringLiteral("[]()")); + QCOMPARE(edit.selectionStart, edit.selectionEnd); + QString typed = edit.text; + typed.insert(edit.selectionStart, QStringLiteral("label")); + QCOMPARE(typed, QStringLiteral("[label]()")); +} + +void TestFormatToolbar::aBackwardsSelectionLinksTheSameWordsAsAForwardOne() +{ + const MarkdownFormat::Edit edit = MarkdownFormat::link( + QStringLiteral("see the docs"), 12, 8); + + QCOMPARE(edit.text, QStringLiteral("see the [docs]()")); + QString typed = edit.text; + typed.insert(edit.selectionStart, QStringLiteral("https://example.org")); + QCOMPARE(typed, QStringLiteral("see the [docs](https://example.org)")); +} + +void TestFormatToolbar::quotingPrefixesEveryLineTheSelectionTouches() +{ + const MarkdownFormat::Edit edit = MarkdownFormat::quote( + QStringLiteral("one\ntwo\nthree"), 0, 7); + + QCOMPARE(edit.text, QStringLiteral("> one\n> two\nthree")); +} + +void TestFormatToolbar::quotingAPartialLineStillQuotesTheWholeLine() +{ + // A selection from the middle of one line into the middle of the next + // must quote both whole lines. Quoting half a line produces markdown that + // means something else entirely. + const MarkdownFormat::Edit edit = MarkdownFormat::quote( + QStringLiteral("one\ntwo\nthree"), 1, 5); + + QCOMPARE(edit.text, QStringLiteral("> one\n> two\nthree")); +} + +void TestFormatToolbar::quotingASingleLineWithNoSelectionQuotesThatLine() +{ + const MarkdownFormat::Edit edit = MarkdownFormat::quote( + QStringLiteral("one\ntwo"), 5, 5); + + QCOMPARE(edit.text, QStringLiteral("one\n> two")); +} + +void TestFormatToolbar::quotingWithTheCursorAtTheEndOfALineQuotesThatLineNotTheNext() +{ + // Position 3 is the end of "one", immediately BEFORE the newline, so the + // cursor is on the first line. Searching backwards from the cursor itself + // rather than from one before it finds that newline and quotes the SECOND + // line, which is the line the user is not on. The off-by-one is invisible + // in every other case because no newline sits at the search position. + const MarkdownFormat::Edit edit = MarkdownFormat::quote( + QStringLiteral("one\ntwo"), 3, 3); + + QCOMPARE(edit.text, QStringLiteral("> one\ntwo")); +} + +void TestFormatToolbar::quotingSelectsTheQuotedLines() +{ + // The quoted block stays selected, so pressing Quote again nests it and + // a following transformation applies to the same lines. + const MarkdownFormat::Edit edit = MarkdownFormat::quote( + QStringLiteral("one\ntwo\nthree"), 1, 5); + + QCOMPARE(edit.text.mid(edit.selectionStart, + edit.selectionEnd - edit.selectionStart), + QStringLiteral("> one\n> two")); +} + +void TestFormatToolbar::quotingTheLastLineKeepsTheRestOfTheText() +{ + // No trailing newline after the last line, so the end-of-text search + // returns -1 and an unguarded implementation truncates everything from + // the selection onwards. + const MarkdownFormat::Edit edit = MarkdownFormat::quote( + QStringLiteral("one\ntwo\nthree"), 9, 9); + + QCOMPARE(edit.text, QStringLiteral("one\ntwo\n> three")); +} + +void TestFormatToolbar::quotingSelectsOnlyTheLineItQuoted() +{ + // The line quoted here is the SECOND one, so a selection that wrongly + // starts at 0 is distinguishable from a correct one. The existing + // quotingSelectsTheQuotedLines fixture starts on the first line, where a + // hardcoded 0 and the right answer coincide: that coincidence let a + // mutation replacing firstLineStart with 0 pass the whole suite. + // + // The damage is not cosmetic. With the wrong selection a second Quote + // press quotes a line the user never selected, and a following Bold + // bolds the wrong text. + const MarkdownFormat::Edit edit = MarkdownFormat::quote( + QStringLiteral("one\ntwo\nthree"), 5, 5); + + QCOMPARE(edit.text, QStringLiteral("one\n> two\nthree")); + QCOMPARE(edit.text.mid(edit.selectionStart, + edit.selectionEnd - edit.selectionStart), + QStringLiteral("> two")); +} + +void TestFormatToolbar::quotingAnAlreadyQuotedLineNestsIt() +{ + // Nests rather than toggling, per the spec, which states there is + // deliberately no live toggle that inserts and removes the quote while + // editing. A second press deepens the quote. Backlog item 135 holds the + // toggle design if that is ever revisited. + const MarkdownFormat::Edit edit = MarkdownFormat::quote( + QStringLiteral("> one"), 0, 5); + + QCOMPARE(edit.text, QStringLiteral("> > one")); +} + +void TestFormatToolbar::quotingAnEmptyLineLeavesTheMarkerWithoutTrailingSpace() +{ + // A blank line inside a quoted block is what continues the block in + // markdown, so it gets the marker. "> " with nothing after it is trailing + // whitespace that several editors and mail clients strip, which would + // break the block; the marker is written bare. + const MarkdownFormat::Edit edit = MarkdownFormat::quote( + QStringLiteral("one\n\ntwo"), 0, 8); + + QCOMPARE(edit.text, QStringLiteral("> one\n>\n> two")); +} + +void TestFormatToolbar::aSelectionPastTheEndIsClamped() +{ + // A stale selection outliving an edit to the buffer would otherwise index + // past the end. QString tolerates that in some calls and not in others, + // so it is clamped once at the entry rather than relied on per call. + QCOMPARE(MarkdownFormat::wrap(QStringLiteral("ab"), 0, 99, + QStringLiteral("**")).text, + QStringLiteral("**ab**")); + QCOMPARE(MarkdownFormat::link(QStringLiteral("ab"), -5, 99).text, + QStringLiteral("[ab]()")); + QCOMPARE(MarkdownFormat::quote(QStringLiteral("ab"), -5, 99).text, + QStringLiteral("> ab")); +} + +void TestFormatToolbar::aSelectionSplittingASurrogatePairKeepsTheCharacterWhole() +{ + // An emoji is two UTF-16 code units, so a boundary at 4 lands BETWEEN + // them. Inserting there splits the character: the result is invalid + // UTF-16 and the emoji is destroyed, not merely moved. + // + // Not reachable by arrow key or mouse, which both move in whole clusters, + // but QTextCursor::setPosition accepts it, so anything computing a + // position arithmetically gets there: a draft restore, a find/replace, a + // template insertion. + const QString emoji = QString::fromUcs4(U"\U0001F600"); + const QString text = QStringLiteral("hi ") + emoji + QStringLiteral(" there"); + QCOMPARE(text.size(), 11); + QVERIFY(text.at(3).isHighSurrogate()); + QVERIFY(text.at(4).isLowSurrogate()); + + // Boundary inside the pair on the closing side. + const MarkdownFormat::Edit a = + MarkdownFormat::wrap(text, 3, 4, QStringLiteral("**")); + QVERIFY2(a.text.isValidUtf16(), "wrap split the surrogate pair"); + QVERIFY2(a.text.contains(emoji), "wrap destroyed the character"); + + // Boundary inside the pair on the opening side. + const MarkdownFormat::Edit b = + MarkdownFormat::wrap(text, 4, 5, QStringLiteral("**")); + QVERIFY2(b.text.isValidUtf16(), "wrap split the surrogate pair"); + QVERIFY2(b.text.contains(emoji), "wrap destroyed the character"); + + const MarkdownFormat::Edit c = MarkdownFormat::link(text, 3, 4); + QVERIFY2(c.text.isValidUtf16(), "link split the surrogate pair"); + QVERIFY2(c.text.contains(emoji), "link destroyed the character"); + + // A COLLAPSED cursor inside the pair must stay collapsed. Nudging its two + // ends in opposite directions would keep the character whole while + // turning "insert an empty pair here" into "wrap the emoji", which is a + // character the user never selected. + const MarkdownFormat::Edit e = + MarkdownFormat::wrap(text, 4, 4, QStringLiteral("**")); + QVERIFY2(e.text.isValidUtf16(), "wrap split the surrogate pair"); + QCOMPARE(e.text, QStringLiteral("hi ****") + emoji + QStringLiteral(" there")); + QCOMPARE(e.selectionStart, e.selectionEnd); + QString typedInto = e.text; + typedInto.insert(e.selectionStart, QStringLiteral("x")); + QCOMPARE(typedInto, + QStringLiteral("hi **x**") + emoji + QStringLiteral(" there")); + + // quote() snaps to line boundaries, so it is immune by construction. + // Asserted rather than assumed, so a later change to how it widens the + // selection cannot quietly lose that. + const MarkdownFormat::Edit d = MarkdownFormat::quote(text, 3, 4); + QVERIFY2(d.text.isValidUtf16(), "quote split the surrogate pair"); + QCOMPARE(d.text, QStringLiteral("> ") + text); +} + +QTEST_APPLESS_MAIN(TestFormatToolbar) +#include "test_formattoolbar.moc" -- cgit v1.2.3 From c72d96d6428f99f06c04868a70cb09c4a9180f98 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 21 Aug 2026 21:17:34 +0200 Subject: feat(compose): the send popup and its undo window, item 123 Three rows in every state so nothing reflows and the window never jumps. The bar changes MODE rather than place: determinate while the countdown drains, because a countdown has measurable progress, and indeterminate once the command starts, because a send does not. That is the pairing item 134's widget was extracted to serve. The delay is where cancelling is safe and it is the only place it is. Nothing has reached a server during the countdown, so Undo means genuinely nothing happened; killing send_command once it runs leaves an UNKNOWN send, which is worse than either clean outcome. Undo therefore disables itself the moment the command starts, and stays visible while disabled: a control that vanishes re-lays out the popup mid-operation, and a greyed Undo says why cancelling is no longer possible where an absent one looks like it was never offered. The test for this asserts the NEGATIVE property, that committed() never fires after Undo, including after the original countdown would have elapsed. Asserting only that undone() fired would pass against a design that ran the command and threw the result away, which is the whole failure the delay exists to prevent. Removing the close BUTTON is not the same as closing the code path, and the first draft did only the former while its comments claimed otherwise. Escape still reached QDialog::reject(), and close() during the countdown hid the window while leaving the timer running, so the send committed with nothing on screen and the only cancel control destroyed: measured, committed=1 on a dialog the user had dismissed. A never-shown dialog did the same, since close() returns early without reaching done(). That is CLAUDE.md's done(int) trap in the one place it costs mail rather than state. Dismissal is REFUSED before commit rather than treated as an implicit Undo, at the user's decision: a close that silently means cancel overloads one gesture with two meanings, while a refusal leaves Undo as the only way out, which is what the popup's single control already says. done(int) refuses pre-commit and forces Accepted after, closeEvent covers the never-shown route done() cannot see, and Undo passes through both. Task 12 needs no special entry point, since it closes after the send finishes and that is post-commit by definition. A refusal must not read as a hang, so the label says how to leave. Making the hint silent was a mutation that SURVIVED, because the text was written in two places and neutering one was masked by the other; extracting it to one function exposed a real defect behind the wrong green, in that the next tick overwrote the hint 100ms later and the refusal was effectively silent anyway. It is held for 1500ms now, with a test that it survives a tick and still releases. setStage is public and Task 12 passes values into it, so it refuses to wind back to CountingDown after commit rather than trusting its caller with an invariant this class documents as inviolable; the label read "Sending in 0..." and the bar returned to determinate. Both m_committed guards carry tests: removing them left the suite green, so two deliberate safety additions rested on reasoning alone. Every route out is asserted, per the rule that a test using close() while the user uses Cancel covers one route of three: close() shown, close() never-shown, Escape bare and with Shift and Ctrl, reject() direct, and Undo, which must still work or the popup is a trap. The status label is sized to the longest string it can hold in the current language rather than to its content: Italian 'Rimozione della bozza...' is longer than 'Removing draft...', and a label sized to content resizes the popup between stages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FXF741wz4SY7j5dqvAxMU5 --- src/CMakeLists.txt | 1 + src/senddialog.cpp | 318 +++++++++++++++++++++++++++ src/senddialog.h | 145 +++++++++++++ tests/CMakeLists.txt | 1 + tests/test_senddialog.cpp | 468 ++++++++++++++++++++++++++++++++++++++++ translations/qtmaildir_it_IT.ts | 31 +++ 6 files changed, 964 insertions(+) create mode 100644 src/senddialog.cpp create mode 100644 src/senddialog.h create mode 100644 tests/test_senddialog.cpp (limited to 'tests/CMakeLists.txt') diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 501c276..83981b2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -19,6 +19,7 @@ add_library(qtmaildir_lib STATIC formattoolbar.cpp tagchip.cpp tagcolors.cpp + senddialog.cpp savequerydialog.cpp tagdialog.cpp tagrules.cpp diff --git a/src/senddialog.cpp b/src/senddialog.cpp new file mode 100644 index 0000000..4a36b7b --- /dev/null +++ b/src/senddialog.cpp @@ -0,0 +1,318 @@ +/* + * 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 "senddialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "busyindicator.h" + +namespace { + +// How often the countdown repaints: smooth enough for a draining bar without +// being a busy loop. It is also the resolution of the countdown itself, since +// tick() subtracts exactly this much rather than consulting a clock. Two +// consequences, both deliberate: a delay that is not a multiple of 100 rounds +// UP to one (250 runs for 300ms), and timer slack accumulates rather than +// being corrected against a clock. Drift is irrelevant at this scale, where +// the number is a courtesy pause and nothing downstream measures it. +constexpr int kTickMs = 100; + +// How long the refused-dismissal hint holds the status label. Longer than a +// tick, or the countdown would overwrite it before it could be read and the +// refusal would be silent in practice; short enough that the countdown the +// user is waiting on is not hidden for any meaningful part of its life. +constexpr qint64 kHintMs = 1500; + +} // namespace + +SendDialog::SendDialog(int delayMs, QWidget *parent) + : QDialog(parent) + , m_remainingMs(qMax(0, delayMs)) + , m_totalMs(qMax(0, delayMs)) +{ + setWindowTitle(tr("Sending")); + + // Modal to the composer, not to the application. Sending from one composer + // must not freeze a second composer or the main window. + setWindowModality(Qt::WindowModal); + + // No close button: during the countdown a bare dismissal is ambiguous, + // since it could equally mean "cancel" or "send now", so Undo is the only + // control that states which. This removes the AFFORDANCE only. Escape, + // close() and the window manager all still reach done(), and that override + // is what actually makes a dismissal safe; keyPressEvent() below merely + // spares the user an Escape that would silently undo. Reasoning about this + // flag alone is what left close() committing a send with no window up. + setWindowFlags((windowFlags() | Qt::CustomizeWindowHint) + & ~Qt::WindowCloseButtonHint); + + auto *layout = new QVBoxLayout(this); + + m_status = new QLabel(this); + m_status->setObjectName(QStringLiteral("sendStatus")); + + // Sized to the LONGEST string it can hold in the current language, not to + // its content. Italian "Rimozione della bozza..." is longer than "Removing + // draft...", so a label sized to whatever it happens to be showing resizes + // the popup between stages. Computed from tr() results at construction, so + // it is correct in whatever language is loaded AT THAT MOMENT. That is + // sufficient here and not in general: main() installs the QTranslator on + // its own stack before any window exists, so no dialog can outlive a + // language change. A runtime language switch would need this recomputed. + const QFontMetrics metrics(m_status->font()); + // The refusal hint is in this list too. It replaces the countdown text in + // the same label, so leaving it out would resize the popup at exactly the + // moment the user is being told the window will not close, which is the + // worst possible time for it to jump. + const QStringList candidates{ + tr("Sending in %1...").arg(99), + tr("Sending..."), + tr("Filing sent copy..."), + tr("Removing draft..."), + tr("Press Undo to stop sending."), + }; + int widest = 0; + for (const QString &candidate : candidates) + widest = qMax(widest, metrics.horizontalAdvance(candidate)); + m_status->setMinimumWidth(widest); + layout->addWidget(m_status); + + m_indicator = new BusyIndicator(this); + m_indicator->setObjectName(QStringLiteral("sendProgress")); + layout->addWidget(m_indicator); + + // Three rows in every state, so nothing reflows: Undo keeps its place and + // its size after it disables rather than vanishing. + auto *buttons = new QHBoxLayout; + buttons->addStretch(); + m_undo = new QPushButton(tr("Undo"), this); + m_undo->setObjectName(QStringLiteral("undoSend")); + buttons->addWidget(m_undo); + layout->addLayout(buttons); + + // Built BEFORE the Undo connection below, which stops it. The lambda would + // read a null m_timer otherwise, and only because nothing can click a + // button mid-constructor does the reverse order happen to survive. + m_timer = new QTimer(this); + m_timer->setObjectName(QStringLiteral("sendCountdown")); + m_timer->setInterval(kTickMs); + connect(m_timer, &QTimer::timeout, this, &SendDialog::tick); + + // Both the button and done() funnel into one place, so the two dismissal + // routes cannot drift into disagreeing about what a cancel does. + connect(m_undo, &QPushButton::clicked, this, [this] { undo(); }); + + if (m_totalMs == 0) { + // Queued rather than immediate, so a caller that connects to + // committed() AFTER constructing the dialog still hears it. Emitting + // from the constructor would send to nobody. + QTimer::singleShot(0, this, &SendDialog::commit); + } else { + setStage(Stage::CountingDown); + m_timer->start(); + } +} + +bool SendDialog::undo() +{ + // Undo is disabled at commit, but a disabled button is a UI property and + // not an invariant. This is the ONE place that can report "nothing was + // sent", so it refuses outright once the command is running rather than + // trusting the button's state. + // + // m_undone is the second half and is NOT redundant: it makes undone() + // fire exactly once however many times this is reached. + if (m_committed || m_undone) + return false; + m_undone = true; + + // The timer stops FIRST. A timer left running commits after the dialog has + // already reported that nothing was sent, which is the one outcome the + // whole delay exists to make impossible. + m_timer->stop(); + m_undo->setEnabled(false); + emit undone(); + + // Undo is the ONE route out before commit, so it is the one caller allowed + // through done()'s refusal. The flag is what distinguishes it from every + // other reject(); it is never cleared, because the dialog is finished. + m_undoing = true; + reject(); + return true; +} + +void SendDialog::refuseDismissal() +{ + // A window that ignores a close reads as a hang, so the refusal says where + // the exit is rather than doing nothing at all. One function because both + // done() and closeEvent() refuse, and two copies of this meant neutering + // either one left the other still setting the text, hiding the regression. + // + // Held for kHintMs, because the countdown's next tick is only kTickMs away + // and would otherwise overwrite the hint before it could be read, leaving + // the refusal effectively silent after all. setStage() honours the hold + // rather than this scheduling a restore, so the countdown keeps running + // underneath and there is no second timer to get out of step. + m_hintUntil = QDateTime::currentMSecsSinceEpoch() + kHintMs; + m_status->setText(tr("Press Undo to stop sending.")); + m_undo->setFocus(); +} + +void SendDialog::keyPressEvent(QKeyEvent *event) +{ + // QDialog maps Escape to reject(). Swallowed WITH ANY MODIFIER: Shift and + // Ctrl variants are the same keystroke as far as intent goes, and letting + // one through would be an undocumented back door to the same dismissal. + // done() would treat it safely as an Undo either way; this just spares the + // user a cancel they did not ask for by reflex. + if (event->key() == Qt::Key_Escape) { + event->accept(); + return; + } + QDialog::keyPressEvent(event); +} + +void SendDialog::done(int result) +{ + // Every dismissal route arrives here, which is the point: close(), the + // window manager, Escape and QDialog's own reject() all converge on + // done(), and guarding any one of them individually leaves the others + // open. Which routes are permitted, and when: + // + // BEFORE COMMIT, nothing closes the dialog except Undo. A close is + // REFUSED, not silently reinterpreted as a cancel: "close means undo" is + // confusing, because the user cannot tell whether dismissing the window + // stopped the send or merely hid it, and the two answers differ by whether + // their mail goes out. The popup carries exactly one control and it says + // what it does. Undo reaches QDialog::done() through m_undoing below. + // + // AFTER COMMIT, the send is in flight and there is nothing left to cancel, + // so any close is honoured. It is forced to Accepted so a caller reading + // result() cannot mistake a running send for a cancelled one. + // + // TASK 12 closes this dialog when the send finishes, and it does so after + // commit by definition, so the ordinary accept()/close() works and needs + // no special entry point. A stray reject() cannot reach the pre-commit + // state at all, which is the property this refusal buys. + if (m_committed) { + QDialog::done(QDialog::Accepted); + return; + } + + if (m_undoing) { + QDialog::done(QDialog::Rejected); + return; + } + + refuseDismissal(); +} + +void SendDialog::closeEvent(QCloseEvent *event) +{ + // Measured against a standalone Qt program, not assumed: close() on a + // dialog that was NEVER SHOWN reaches closeEvent() but returns BEFORE + // done(), so done()'s refusal alone would let that one route through. A + // shown dialog reaches both, and ignoring the event here stops it before + // done() is consulted. + if (!m_committed && !m_undoing) { + event->ignore(); + refuseDismissal(); + return; + } + QDialog::closeEvent(event); +} + +void SendDialog::tick() +{ + m_remainingMs -= kTickMs; + if (m_remainingMs <= 0) { + commit(); + return; + } + setStage(Stage::CountingDown); +} + +void SendDialog::commit() +{ + // Idempotent: a stray tick racing the singleShot must not emit twice. + if (m_committed) + return; + + m_timer->stop(); + m_committed = true; + + // Disabled, never hidden. A greyed Undo says why cancelling is no longer + // possible; an absent one only looks like it was never offered. + m_undo->setEnabled(false); + + setStage(Stage::Sending); + emit committed(); +} + +void SendDialog::setStage(Stage stage) +{ + // The enum is documented "in order", so the class enforces that rather + // than trusting its caller: Task 12 passes values from this public enum, + // and winding back would relabel a running send "Sending in 0..." and + // redraw a full countdown bar under it, offering a cancel that no longer + // exists. Only the backwards step is refused; the forward stages are the + // caller's to drive. + if (m_committed && stage == Stage::CountingDown) + return; + + // The refusal hint outranks the countdown text for as long as it is held. + // Only the countdown is suppressed: a stage change is a real event and + // must always be shown, and commit() clears the hold anyway. + if (stage == Stage::CountingDown + && QDateTime::currentMSecsSinceEpoch() < m_hintUntil) { + m_indicator->setProgress(m_remainingMs, m_totalMs); + return; + } + + switch (stage) { + case Stage::CountingDown: + // Rounded up, so a countdown with 1ms left still reads "1" rather than + // sitting on "0" for a tick. + m_status->setText(tr("Sending in %1...") + .arg((m_remainingMs + 999) / 1000)); + m_indicator->setProgress(m_remainingMs, m_totalMs); + return; + case Stage::Sending: + m_status->setText(tr("Sending...")); + break; + case Stage::FilingSentCopy: + m_status->setText(tr("Filing sent copy...")); + break; + case Stage::RemovingDraft: + m_status->setText(tr("Removing draft...")); + break; + } + + // Everything past the countdown: the duration stops being knowable, so the + // same widget switches from a fraction to an animation. + m_indicator->setBusy(true); +} diff --git a/src/senddialog.h b/src/senddialog.h new file mode 100644 index 0000000..a930c3d --- /dev/null +++ b/src/senddialog.h @@ -0,0 +1,145 @@ +/* + * 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. + */ + +#pragma once + +#include +#include + +class BusyIndicator; +class QLabel; +class QCloseEvent; +class QKeyEvent; +class QPushButton; +class QTimer; + +/// Owns a send from the cancellable countdown through to completion. +/// +/// The delay is where cancelling is SAFE and it is the only place it is. +/// Nothing has reached a server during the countdown, so Undo means genuinely +/// nothing happened. Killing send_command once it runs leaves an UNKNOWN send: +/// the message may have reached the server in full before the kill, which is +/// worse than either clean outcome. So there is no cancel after commit, and +/// isCommitted() is the line between the two. +/// +/// Three rows in every state, so nothing reflows and the window never jumps: +/// a status label, the bar, and Undo. +/// +/// The bar CHANGES MODE, it does not change place. Determinate while the +/// countdown drains, because a countdown has measurable progress; +/// indeterminate once the command starts, because a send does not. +/// +/// Modal to the composer, NOT to the application: sending from one composer +/// must not freeze a second composer or the main window. +/// +/// DISMISSAL IS A THIRD ROUTE TO THE SAME FAILURE, and removing the close +/// button only removes the affordance. Escape, the window manager, close() and +/// QDialog's own machinery all still reach done(); see done() and closeEvent() +/// below, which are the two places that cover them. An earlier revision +/// reasoned about Escape and the titlebar button alone and left close() +/// committing a send with no window on screen. +/// +/// Before commit, Undo is the ONLY way out and every other route is refused. +class SendDialog : public QDialog +{ + Q_OBJECT + +public: + /// \p delayMs of zero skips the countdown and sends at once. + explicit SendDialog(int delayMs, QWidget *parent = nullptr); + + /// The stages, in order. Each sets the label; every stage after the + /// countdown leaves the bar indeterminate. + enum class Stage { CountingDown, Sending, FilingSentCopy, RemovingDraft }; + Q_ENUM(Stage) + + void setStage(Stage stage); + + /// True once the countdown has elapsed and the command has started, after + /// which cancelling is no longer possible. + bool isCommitted() const { return m_committed; } + +signals: + /// The countdown elapsed or was skipped: the caller should start sending. + void committed(); + + /// Undo was pressed during the countdown. NOTHING has been sent. + void undone(); + +protected: + /// Swallows Escape, with any modifiers. QDialog maps it to reject(), and + /// during the countdown a bare dismissal is ambiguous in exactly the way + /// the constructor describes; Undo is the control that says which it means. + void keyPressEvent(QKeyEvent *event) override; + + /// The single choke point for every dismissal route, which is why the + /// close button's removal was not enough on its own: QDialog reaches + /// reject() from the window manager, from close(), and from its own + /// machinery, and all of them arrive here. + /// + /// During the countdown a close is REFUSED. "Close means undo" is + /// confusing: the user cannot tell whether dismissing the window stopped + /// the send or merely hid it, and the two answers differ by whether their + /// mail goes out. Undo is the only way out, which is what the popup's + /// single control already says. After commit any close is honoured, since + /// there is nothing left to cancel, and it is forced to Accepted so a + /// caller reading result() cannot mistake a running send for a cancelled + /// one. Task 12 closes the dialog after the send finishes, which is + /// post-commit by definition and so needs no special entry point. + void done(int result) override; + + /// CLAUDE.md's companion trap: close() on a widget that was never shown + /// returns early WITHOUT reaching done(), so done()'s refusal alone would + /// let exactly that one route through. Refuses on the same terms. + void closeEvent(QCloseEvent *event) override; + +private: + /// The one place that can report "nothing was sent". Returns false, and + /// does nothing at all, once the send has committed. Both the Undo button + /// and every dismissal route funnel through it. + bool undo(); + + /// Shows the hint that Undo is the only way out, and holds it long enough + /// to be read. One function because both refusal sites call it. + void refuseDismissal(); + + void tick(); + void commit(); + + QLabel *m_status = nullptr; + BusyIndicator *m_indicator = nullptr; + QPushButton *m_undo = nullptr; + QTimer *m_timer = nullptr; + + int m_remainingMs = 0; + int m_totalMs = 0; + bool m_committed = false; + + /// Set by the first undo(), so undone() is emitted exactly once however + /// many dismissal routes fire. A shown dialog's close() reaches BOTH + /// closeEvent() and done(). + bool m_undone = false; + + /// Deadline until which the refusal hint holds the status label against + /// the countdown's own text. Zero when no hint is showing. + qint64 m_hintUntil = 0; + + /// Set only by undo(), and what lets that one route through done()'s + /// pre-commit refusal. Every other reject() is turned away. + bool m_undoing = false; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b28bf6f..fc19b01 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -75,6 +75,7 @@ add_qtmaildir_test(draftstore) add_qtmaildir_test(messagesender) add_qtmaildir_test(composecontext) add_qtmaildir_test(formattoolbar) +add_qtmaildir_test(senddialog) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up diff --git a/tests/test_senddialog.cpp b/tests/test_senddialog.cpp new file mode 100644 index 0000000..ac8c234 --- /dev/null +++ b/tests/test_senddialog.cpp @@ -0,0 +1,468 @@ +/* + * 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 +#include +#include + +#include "busyindicator.h" +#include "senddialog.h" + +class TestSendDialog : public QObject +{ + Q_OBJECT + +private slots: + void theBarIsDeterminateWhileCountingDown(); + void theCountdownCommitsWhenItElapses(); + void aZeroDelayCommitsImmediately(); + void undoDuringTheCountdownEmitsUndoneAndNeverCommits(); + void undoDisablesItselfOnceTheCommandStarts(); + void theBarBecomesIndeterminateWhenSending(); + void undoStaysVisibleAfterItDisables(); + void theStatusLabelIsWideEnoughForEveryStage(); + void closingDuringTheCountdownIsRefused(); + void closingADialogThatWasNeverShownIsAlsoRefused(); + void theRefusalHintSurvivesTheNextCountdownTick(); + void rejectDuringTheCountdownIsRefused(); + void escapeDuringTheCountdownIsRefused(); + void undoIsTheOneRouteThatClosesBeforeCommit(); + void closingAfterCommitReportsAcceptedAndDoesNotUndo(); + void undoAfterCommitIsRefused(); + void everyStageSetsItsOwnLabelAndLeavesTheBarBusy(); + void windingBackToCountingDownAfterCommitIsRefused(); +}; + +void TestSendDialog::theBarIsDeterminateWhileCountingDown() +{ + // A countdown has measurable progress, so the bar drains rather than + // animating. This is the half of BusyIndicator MainWindow never uses: the + // status bar's sync indicator is indeterminate for its whole life. + // + // A generous delay so the assertion cannot race the countdown's own end, + // which would flip the bar to indeterminate for a legitimate reason and + // report a defect that is not there. + SendDialog dialog(5000); + dialog.show(); + + auto *indicator = dialog.findChild( + QStringLiteral("sendProgress")); + QVERIFY2(indicator, "the dialog has no BusyIndicator named sendProgress"); + QVERIFY2(indicator->isDeterminate(), + "the bar was animating during a countdown that has a known end"); +} + +void TestSendDialog::theCountdownCommitsWhenItElapses() +{ + // A short delay rather than waiting out the shipped default: what is being + // tested is that the countdown ends in a commit, not how long it is. + SendDialog dialog(150); + QSignalSpy spy(&dialog, &SendDialog::committed); + dialog.show(); + + QVERIFY2(spy.wait(3000), "the countdown never committed"); + QCOMPARE(spy.count(), 1); + QVERIFY(dialog.isCommitted()); +} + +void TestSendDialog::aZeroDelayCommitsImmediately() +{ + // send_delay_ms = 0 sends at once, for anyone who finds the delay + // irritating. It must still be a queued commit rather than one inside the + // constructor, or a caller connecting to committed() after constructing the + // dialog would never hear it. + SendDialog dialog(0); + QSignalSpy spy(&dialog, &SendDialog::committed); + dialog.show(); + + QVERIFY2(spy.wait(1000), "a zero delay never committed"); + QCOMPARE(spy.count(), 1); + QVERIFY(dialog.isCommitted()); +} + +void TestSendDialog::undoDuringTheCountdownEmitsUndoneAndNeverCommits() +{ + // THE test for this feature, and the property that matters is the NEGATIVE + // one. A test asserting only that undone() fired would pass against a + // design that started the send anyway and threw the result away, which is + // the whole failure the delay exists to prevent. Nothing has reached a + // server during the countdown, so Undo must mean that nothing happened. + SendDialog dialog(2000); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + auto *undo = dialog.findChild(QStringLiteral("undoSend")); + QVERIFY2(undo, "the dialog has no button named undoSend"); + QVERIFY2(undo->isEnabled(), "Undo was dead during the countdown"); + + undo->click(); + + QCOMPARE(undoneSpy.count(), 1); + QCOMPARE(committedSpy.count(), 0); + + // Past the original deadline. A timer left running would commit here, after + // the dialog has already reported that nothing was sent. + QTest::qWait(2500); + QVERIFY2(committedSpy.count() == 0, + "the countdown committed after Undo was pressed"); +} + +void TestSendDialog::undoDisablesItselfOnceTheCommandStarts() +{ + // There is no cancel after commit. Killing send_command once it runs leaves + // an UNKNOWN send: the message may have reached the server in full before + // the kill, which is worse than either clean outcome. + SendDialog dialog(100); + dialog.show(); + + auto *undo = dialog.findChild(QStringLiteral("undoSend")); + QVERIFY(undo); + + QSignalSpy spy(&dialog, &SendDialog::committed); + QVERIFY2(spy.wait(3000), "the countdown never committed"); + + QVERIFY2(!undo->isEnabled(), + "Undo was still live after the send command started"); +} + +void TestSendDialog::theBarBecomesIndeterminateWhenSending() +{ + // The bar CHANGES MODE, it does not change place: a send has no measurable + // progress, so the same widget stops drawing a fraction and starts + // animating, and nothing in the popup reflows. + SendDialog dialog(100); + dialog.show(); + + auto *indicator = dialog.findChild( + QStringLiteral("sendProgress")); + QVERIFY(indicator); + QVERIFY(indicator->isDeterminate()); + + dialog.setStage(SendDialog::Stage::Sending); + QVERIFY2(!indicator->isDeterminate(), + "the bar kept the countdown's fraction while sending"); +} + +void TestSendDialog::undoStaysVisibleAfterItDisables() +{ + // A control that vanishes re-lays out the popup mid-operation, and a greyed + // Undo says WHY cancelling is no longer possible where an absent one only + // looks like it was never offered. + SendDialog dialog(100); + dialog.show(); + + auto *undo = dialog.findChild(QStringLiteral("undoSend")); + QVERIFY(undo); + + QSignalSpy spy(&dialog, &SendDialog::committed); + QVERIFY2(spy.wait(3000), "the countdown never committed"); + + QVERIFY2(undo->isVisibleTo(&dialog), + "Undo disappeared instead of greying out"); +} + +void TestSendDialog::theStatusLabelIsWideEnoughForEveryStage() +{ + // The label is sized to the LONGEST string it can hold in the current + // language, not to its content, so the popup does not resize between + // stages. Asserted against the metrics of the strings themselves rather + // than a constant, so it holds in whatever language is loaded. + SendDialog dialog(2000); + dialog.show(); + + auto *status = dialog.findChild(QStringLiteral("sendStatus")); + QVERIFY2(status, "the dialog has no label named sendStatus"); + + const QFontMetrics metrics(status->font()); + const QStringList candidates{ + SendDialog::tr("Sending in %1...").arg(99), + SendDialog::tr("Sending..."), + SendDialog::tr("Filing sent copy..."), + SendDialog::tr("Removing draft..."), + SendDialog::tr("Press Undo to stop sending."), + }; + int widest = 0; + for (const QString &candidate : candidates) + widest = qMax(widest, metrics.horizontalAdvance(candidate)); + + QVERIFY2(status->minimumWidth() >= widest, + "the status label was sized to its content, so the popup will " + "resize when a longer stage name arrives"); +} + +void TestSendDialog::closingDuringTheCountdownIsRefused() +{ + // The same failure as the Undo test, reached by a different door. Removing + // the close BUTTON removes the visual affordance, not the code path: the + // window manager, close() and QDialog's own machinery all still reach + // done(). Left unguarded, close() hides the window and leaves the timer + // running, so the send starts with no window on screen and the only cancel + // control destroyed. + // + // The close is REFUSED rather than reinterpreted as an Undo, at the user's + // call: "close means undo is confusing", because a dismissed window cannot + // tell you whether it stopped the send or merely hid it. So the dialog + // stays up, the send stays scheduled, and Undo remains the only way out. + SendDialog dialog(2000); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + QVERIFY2(!dialog.close(), "close() during the countdown was accepted"); + + QVERIFY2(dialog.isVisible(), + "the dialog vanished on a close it was supposed to refuse"); + QVERIFY2(undoneSpy.count() == 0, + "a refused close silently undid the send anyway"); + + // Refusing must not be silent: a window that ignores a close reads as a + // hang, so the popup has to say where the exit is. + auto *status = dialog.findChild(QStringLiteral("sendStatus")); + QVERIFY(status); + QVERIFY2(status->text().contains(QStringLiteral("Undo")), + "a refused close gave the user no hint that Undo is the way out"); + + // The send was never cancelled, so it still goes out. That is the whole + // point of refusing rather than undoing. + QVERIFY2(committedSpy.wait(3000), + "the refused close cancelled the send after all"); +} + +void TestSendDialog::closingADialogThatWasNeverShownIsAlsoRefused() +{ + // CLAUDE.md's documented companion trap: close() on a widget that was + // never shown returns early WITHOUT reaching done(), so a refusal written + // only in done() would miss this one route entirely. The countdown is + // running either way, because it starts in the constructor rather than on + // show(). Refused on the same terms as the shown case. + SendDialog dialog(2000); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + + QVERIFY2(!dialog.close(), + "close() on an unshown dialog slipped past the refusal"); + QVERIFY2(undoneSpy.count() == 0, + "closing an unshown dialog undid the send"); + + QVERIFY2(committedSpy.wait(3000), + "the unshown dialog's send was cancelled by a refused close"); +} + +void TestSendDialog::theRefusalHintSurvivesTheNextCountdownTick() +{ + // Without a hold the hint lives for one tick, which is 100ms, and the + // countdown text overwrites it before it can be read. A refusal the user + // cannot see is a window that ignores them, which reads as a hang, so the + // hold is what makes the refusal honest rather than decorative. + SendDialog dialog(5000); + dialog.show(); + + auto *status = dialog.findChild(QStringLiteral("sendStatus")); + QVERIFY(status); + + dialog.close(); + const QString hint = status->text(); + QVERIFY2(hint.contains(QStringLiteral("Undo")), "no hint on refusal"); + + // Several ticks later, well past the point the countdown would have + // reclaimed the label. + QTest::qWait(500); + QCOMPARE(status->text(), hint); + + // And it does eventually give the label back, or the countdown would be + // hidden for the rest of its life. + QTest::qWait(1500); + QVERIFY2(status->text() != hint, + "the hint never released the label back to the countdown"); +} + +void TestSendDialog::rejectDuringTheCountdownIsRefused() +{ + // reject() is the route neither close() nor Escape goes through directly, + // and it is the one a caller reaches for. CLAUDE.md's rule is that every + // route out gets asserted: "a test used close() and the user used Cancel" + // is the documented way one of three gets missed. + SendDialog dialog(2000); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + dialog.reject(); + + QVERIFY2(dialog.isVisible(), "reject() dismissed the countdown"); + QVERIFY2(undoneSpy.count() == 0, "reject() undid the send"); + QVERIFY2(committedSpy.wait(3000), "reject() cancelled the send after all"); +} + +void TestSendDialog::escapeDuringTheCountdownIsRefused() +{ + // Escape is QDialog's built-in reject(), and swallowing it in + // keyPressEvent is only the first line: done() refuses it too, so the + // dialog is safe even if the key handler is ever removed. + SendDialog dialog(2000); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + QTest::keyClick(&dialog, Qt::Key_Escape); + QVERIFY2(dialog.isVisible(), "Escape dismissed the countdown"); + + // With modifiers too, so neither is an undocumented back door. + QTest::keyClick(&dialog, Qt::Key_Escape, Qt::ShiftModifier); + QTest::keyClick(&dialog, Qt::Key_Escape, Qt::ControlModifier); + QVERIFY2(dialog.isVisible(), "a modified Escape dismissed the countdown"); + + QVERIFY2(undoneSpy.count() == 0, "Escape undid the send"); + QVERIFY2(committedSpy.wait(3000), "Escape cancelled the send after all"); +} + +void TestSendDialog::undoIsTheOneRouteThatClosesBeforeCommit() +{ + // The counterpart to the four refusals above: having refused every other + // way out, the one remaining control must actually work, or the popup is + // a trap with no exit at all. + SendDialog dialog(2000); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + auto *undo = dialog.findChild(QStringLiteral("undoSend")); + QVERIFY(undo); + undo->click(); + + QCOMPARE(undoneSpy.count(), 1); + QVERIFY2(!dialog.isVisible(), "Undo did not close the dialog"); + QCOMPARE(dialog.result(), int(QDialog::Rejected)); +} + +void TestSendDialog::closingAfterCommitReportsAcceptedAndDoesNotUndo() +{ + // After commit there is nothing to undo, so closing is permitted. What it + // must NOT do is report Rejected: a caller inspecting result() would read + // a send that is running as one that was cancelled, and undone() must stay + // silent because the message is on its way. + SendDialog dialog(100); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + dialog.show(); + + QVERIFY2(committedSpy.wait(3000), "the countdown never committed"); + QVERIFY(dialog.isCommitted()); + + dialog.close(); + + QCOMPARE(undoneSpy.count(), 0); + QVERIFY2(dialog.result() != QDialog::Rejected, + "closing a committed dialog reported the send as cancelled"); +} + +void TestSendDialog::undoAfterCommitIsRefused() +{ + // Undo is disabled at commit, but a disabled button is a UI property, not + // an invariant. This asserts the handler's own guard, so a future change + // that re-enables the button cannot turn it back into a claim that nothing + // was sent while send_command is already running. + SendDialog dialog(100); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + QSignalSpy undoneSpy(&dialog, &SendDialog::undone); + dialog.show(); + + QVERIFY2(committedSpy.wait(3000), "the countdown never committed"); + + auto *undo = dialog.findChild(QStringLiteral("undoSend")); + QVERIFY(undo); + + // Deliberately re-enabled, to reach the handler that the disabled state + // would otherwise hide. This is the mutation a future edit could make by + // accident; the guard behind it is what this test is for. + undo->setEnabled(true); + undo->click(); + + QVERIFY2(undoneSpy.count() == 0, + "Undo claimed nothing was sent after the send command started"); +} + +void TestSendDialog::everyStageSetsItsOwnLabelAndLeavesTheBarBusy() +{ + // Walks all four, because a break accidentally deleted from one case would + // fall through to the next and nothing else would notice. FilingSentCopy + // and RemovingDraft are also the two whose Italian strings drove the whole + // label-width design, so leaving them unexercised would test the sizing of + // strings nothing ever displays. + SendDialog dialog(2000); + dialog.show(); + + auto *status = dialog.findChild(QStringLiteral("sendStatus")); + auto *indicator = dialog.findChild( + QStringLiteral("sendProgress")); + QVERIFY(status); + QVERIFY(indicator); + + const QString countingDown = status->text(); + QVERIFY2(!countingDown.isEmpty(), "the countdown showed no text"); + QVERIFY(indicator->isDeterminate()); + + QStringList seen; + const QVector stages{ + SendDialog::Stage::Sending, + SendDialog::Stage::FilingSentCopy, + SendDialog::Stage::RemovingDraft, + }; + for (SendDialog::Stage stage : stages) { + dialog.setStage(stage); + QVERIFY2(!status->text().isEmpty(), "a stage set no text at all"); + QVERIFY2(!indicator->isDeterminate(), + "a post-countdown stage left the bar drawing a fraction"); + seen << status->text(); + } + + // Distinct from each other and from the countdown: a fallthrough would + // show the following stage's text and collapse two of these into one. + seen << countingDown; + QCOMPARE(QSet(seen.begin(), seen.end()).size(), seen.size()); +} + +void TestSendDialog::windingBackToCountingDownAfterCommitIsRefused() +{ + // setStage() is public and Task 12 passes values from the public enum. The + // enum is documented "in order", so the class enforces that itself rather + // than trusting its caller: winding back would relabel a running send + // "Sending in 0..." and redraw a full countdown bar under it, offering a + // cancel that no longer exists. + SendDialog dialog(100); + QSignalSpy committedSpy(&dialog, &SendDialog::committed); + dialog.show(); + + QVERIFY2(committedSpy.wait(3000), "the countdown never committed"); + + auto *status = dialog.findChild(QStringLiteral("sendStatus")); + auto *indicator = dialog.findChild( + QStringLiteral("sendProgress")); + const QString sending = status->text(); + + dialog.setStage(SendDialog::Stage::CountingDown); + + QCOMPARE(status->text(), sending); + QVERIFY2(!indicator->isDeterminate(), + "the bar drew a countdown fraction over a running send"); +} + +QTEST_MAIN(TestSendDialog) +#include "test_senddialog.moc" diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index a45b4f2..83ac087 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -1380,6 +1380,37 @@ Esiste già una ricerca salvata di nome '%1' e verrà sostituita. + + SendDialog + + Sending + Invio in corso + + + Sending in %1... + Invio tra %1... + + + Sending... + Invio in corso... + + + Filing sent copy... + Archiviazione della copia inviata... + + + Removing draft... + Rimozione della bozza... + + + Press Undo to stop sending. + Premi Annulla per fermare l'invio. + + + Undo + Annulla + + SyncPhaseTracker -- cgit v1.2.3 From f7948d98fe09c551856b04ba8a473dbd40649dc3 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 23 Aug 2026 20:39:06 +0200 Subject: feat(hooks): own the notmuch hooks, and keep sent mail out of the inbox The post-new hook and its rule store move here from the companion mailctl project, which is being retired. Nothing else was shared between the two, so this is a plain move: mailrules.py is stdlib-only and post-new imports only it. With that in hand, the hook learns the one thing it could not know before. notmuch's new.tags applies `inbox` to every file it indexes, and it cannot tell an arrival from the copy this application files into a sent folder after a send, so sent mail turned up in the inbox view and in any hand-typed tag:inbox search. Drafts arrived the same way, through the composer's autosave. 786 messages were affected on the developer's own index. qtmaildirconf.py reads the sent and drafts folders out of qtmaildir.conf, so adding an account fixes itself. Reading the application's own config is not the cross-repo coupling it would have been last week: this repo owns the hook now. Three properties are load-bearing: - it is NOT a relaxation of PROTECTED_REMOVALS, which is about a rule removing `inbox` from mail whose provenance the hook cannot judge. Here the provenance is the file's own path, and `inbox` was never true of it. - only `inbox`. maildir.synchronize_flags is true, so removing `unread` would rewrite Maildir filenames and reach the server on the next mbsync. - an empty folder list means NOTHING, never an empty query, which notmuch reads as "match everything". A system with no qtmaildir config must be left alone rather than have every new message stripped. Trash is deliberately not in the list: Delete leaves `inbox` on a trashed message so Restore can put it back where it came from. The three Python suites run under ctest rather than beside it as scripts someone remembers to run, since this code tags real mail unattended on every sync. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q2koFevoSxTLhfexJTZWQd --- .gitignore | 3 + assets/hooks/mailrules.py | 261 ++++++++++++++++++++++++++++ assets/hooks/post-new | 188 ++++++++++++++++++++ assets/hooks/qtmaildirconf.py | 134 +++++++++++++++ assets/hooks/test_mailrules.py | 278 ++++++++++++++++++++++++++++++ assets/hooks/test_post_new.py | 340 +++++++++++++++++++++++++++++++++++++ assets/hooks/test_qtmaildirconf.py | 179 +++++++++++++++++++ tests/CMakeLists.txt | 20 +++ 8 files changed, 1403 insertions(+) create mode 100755 assets/hooks/mailrules.py create mode 100755 assets/hooks/post-new create mode 100755 assets/hooks/qtmaildirconf.py create mode 100755 assets/hooks/test_mailrules.py create mode 100755 assets/hooks/test_post_new.py create mode 100755 assets/hooks/test_qtmaildirconf.py (limited to 'tests/CMakeLists.txt') diff --git a/.gitignore b/.gitignore index 3437dad..6054f6c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ HANDOFF.md # The .ts is tracked; the .qm is generated from it by lrelease. *.qm + +# Python bytecode from the notmuch hooks in assets/hooks/. +__pycache__/ diff --git a/assets/hooks/mailrules.py b/assets/hooks/mailrules.py new file mode 100755 index 0000000..dbb80a5 --- /dev/null +++ b/assets/hooks/mailrules.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +# +# 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. +"""Shared notmuch tagging-rule store. + +The rules live in ~/.config/mailrules/rules.json and are read by both this +tool and qtmaildir, so the format belongs to neither: a field one tool does +not understand is preserved verbatim across a save by the other. + +A rule carries NO scope. The post-new hook supplies `tag:new`, a dry run +supplies nothing and counts against the whole corpus. This is what lets one +rule serve arrivals, a dry run, and (later) a backfill over history. + +Stdlib only, deliberately: this module is imported by a notmuch hook that +runs on every sync, and mailctl has no dependencies to inherit. +""" + +import json +import os +import re +import tempfile +from dataclasses import dataclass, field +from pathlib import Path + +FORMAT_VERSION = 1 +DEFAULT_STAGE = 50 + +# Fields this version understands. Anything else in a rule object is kept in +# `unknown` and written back untouched, which is what makes the file neutral +# rather than this tool's file that another program may read. +KNOWN_KEYS = {"id", "stage", "enabled", "add", "remove", "query", "note"} + +# An id is a handle, not a display name: a UI selects on it and a diff tracks +# it. Tags may contain '/' and may be renamed; ids may not. +ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + +@dataclass +class Rule: + id: str + query: str + add: list = field(default_factory=list) + remove: list = field(default_factory=list) + stage: int = DEFAULT_STAGE + enabled: bool = True + note: str = "" + unknown: dict = field(default_factory=dict) + + +@dataclass +class Store: + rules: list = field(default_factory=list) + warnings: list = field(default_factory=list) + unknown: dict = field(default_factory=dict) + # Distinguishes "no file yet" from "a file that would not load". The hook + # treats them differently: the first is a fresh install, the second must + # not consume tag:new. + missing: bool = False + failed: bool = False + + +def default_path(): + """$XDG_CONFIG_HOME/mailrules/rules.json, or ~/.config/... as fallback. + + No hardcoded home directory: both tools must resolve the same path, and + a user with XDG_CONFIG_HOME set expects it honoured. + """ + base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config" + return Path(base) / "mailrules" / "rules.json" + + +def load(path=None): + """Read the store. Never raises for a bad file: problems land in + Store.warnings and the offending rule is dropped, so one malformed rule + cannot stop the other nineteen from running.""" + path = Path(path) if path else default_path() + store = Store() + + if not path.exists(): + store.missing = True + return store + + try: + raw = json.loads(path.read_text()) + except (json.JSONDecodeError, OSError) as exc: + store.warnings.append(f"{path}: cannot read: {exc}") + store.failed = True + return store + + if not isinstance(raw, dict): + store.warnings.append(f"{path}: top level is not an object") + store.failed = True + return store + + version = raw.get("version", FORMAT_VERSION) + if version != FORMAT_VERSION: + store.warnings.append( + f"{path}: format version {version} is newer than this tool " + f"understands ({FORMAT_VERSION}); refusing to guess") + store.failed = True + return store + + store.unknown = {k: v for k, v in raw.items() + if k not in ("version", "rules")} + + seen = set() + for index, obj in enumerate(raw.get("rules", [])): + rule = _parse_rule(obj, index, seen, store.warnings) + if rule is not None: + seen.add(rule.id) + store.rules.append(rule) + + return store + + +def scoped_query(rule, scope): + """The rule's query narrowed by `scope`, or the bare query when scope is + empty. + + The parentheses are load-bearing. notmuch's `and` binds tighter than + `or`, so `tag:new and a or b` means `(tag:new and a) or b`: a rule that + is a disjunction of senders would escape its scope and match the whole + corpus. Do not remove them, and do not build this string anywhere else. + """ + if not scope: + return rule.query + return f"{scope} and ({rule.query})" + + +def tag_arguments(rule): + """The +tag/-tag arguments for `notmuch tag`, adds before removes.""" + return [f"+{t}" for t in rule.add] + [f"-{t}" for t in rule.remove] + + +def save(store, path=None): + """Write the store atomically: a temp file in the same directory, then + rename. Rename within a filesystem is atomic, so a concurrent reader sees + either the old file or the new one and never a partial write. + + There is no locking. Last writer wins on a true collision, which is + accepted for a single-user setup; the failure that would actually hurt is + a truncated read by the hook, and rename eliminates it. + """ + path = Path(path) if path else default_path() + path.parent.mkdir(parents=True, exist_ok=True) + + payload = dict(store.unknown) + payload["version"] = FORMAT_VERSION + payload["rules"] = [_rule_to_dict(r) for r in store.rules] + + # delete=False plus an explicit replace: NamedTemporaryFile would unlink + # the file on close, and the rename is the whole point. + handle = tempfile.NamedTemporaryFile( + mode="w", dir=path.parent, prefix=".rules-", suffix=".tmp", + delete=False) + try: + with handle: + json.dump(payload, handle, indent=2, ensure_ascii=False) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(handle.name, path) + except BaseException: + # A failed write must not leave the temp file beside the real one. + try: + os.unlink(handle.name) + except OSError: + pass + raise + + +def _rule_to_dict(rule): + """Known fields first in a stable order, then anything this version did + not understand. Stable ordering keeps a diff of this file readable.""" + out = { + "id": rule.id, + "stage": rule.stage, + "enabled": rule.enabled, + "add": list(rule.add), + "remove": list(rule.remove), + "query": rule.query, + "note": rule.note, + } + out.update(rule.unknown) + return out + + +def ordered(rules): + """Enabled rules in execution order: by stage ascending, ties by position. + + `sorted` is stable, so sorting on stage alone preserves file order within + a stage. That is the tie-break the format promises, and it is why this + does not sort on (stage, id): an id-sorted tie would reorder rules a user + deliberately sequenced. + """ + return sorted([r for r in rules if r.enabled], key=lambda r: r.stage) + + +def _parse_rule(obj, index, seen, warnings): + """One rule, or None with a warning appended. `index` names the rule when + it has no usable id of its own.""" + where = f"rule #{index + 1}" + + if not isinstance(obj, dict): + warnings.append(f"{where}: not an object; dropped") + return None + + rule_id = obj.get("id", "") + if not isinstance(rule_id, str) or not ID_RE.match(rule_id): + warnings.append( + f"{where}: id '{rule_id}' is missing or not lowercase " + f"letters, digits and dashes; dropped") + return None + + if rule_id in seen: + warnings.append(f"rule '{rule_id}': duplicate id; keeping the first") + return None + + query = obj.get("query", "") + if not isinstance(query, str) or not query.strip(): + warnings.append(f"rule '{rule_id}': no query; dropped") + return None + + add = [t for t in obj.get("add", []) if isinstance(t, str) and t.strip()] + remove = [t for t in obj.get("remove", []) if isinstance(t, str) and t.strip()] + if not add and not remove: + warnings.append( + f"rule '{rule_id}': adds and removes nothing; dropped") + return None + + try: + stage = int(obj.get("stage", DEFAULT_STAGE)) + except (TypeError, ValueError): + warnings.append( + f"rule '{rule_id}': stage '{obj.get('stage')}' is not a " + f"number; using {DEFAULT_STAGE}") + stage = DEFAULT_STAGE + + return Rule( + id=rule_id, + query=query, + add=add, + remove=remove, + stage=stage, + enabled=bool(obj.get("enabled", True)), + note=obj.get("note", "") if isinstance(obj.get("note", ""), str) else "", + unknown={k: v for k, v in obj.items() if k not in KNOWN_KEYS}, + ) diff --git a/assets/hooks/post-new b/assets/hooks/post-new new file mode 100755 index 0000000..5102103 --- /dev/null +++ b/assets/hooks/post-new @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# +# 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. +"""notmuch post-new hook: auto-tag incoming mail from the shared rule store. + +Runs after every `notmuch new`. Reads ~/.config/mailrules/rules.json and +applies each enabled rule scoped to `tag:new`, in stage order, then consumes +the `tag:new` marker. + +Rules may add any tag and remove most, but this hook REFUSES to remove `unread` +or `inbox` unattended and skips any rule that asks: see PROTECTED_REMOVALS +below for why, and for the conditions under which that restriction should be +lifted. It is expected to be relaxed once there is a story for confirming such +a rule before it runs. + +Requires `new` in [new] tags= in ~/.notmuch-config. Without it every scoped +query matches nothing and this silently no-ops. + +Install: copy to /.notmuch/hooks/post-new, with mailrules.py +importable (same directory, or on PYTHONPATH). +""" + +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import mailrules +import qtmaildirconf + +SCOPE = "tag:new" + +# Tags this hook refuses to REMOVE, whatever a rule says. +# +# maildir.synchronize_flags is true, so `unread` is not just an index entry: +# removing it rewrites Maildir filenames and propagates to the server on the +# next mbsync. `inbox` is what keeps mail visible at all. Unattended, on every +# sync, either one silently reorganizes a mailbox in a way that is tedious to +# undo and reaches other clients before anyone notices. +# +# Adding these tags is untouched, and so is removing anything else: a rule may +# still strip `promo` or any tag of its own making. +# +# DELIBERATELY CONSERVATIVE, AND EXPECTED TO BE RELAXED. The rules in use today +# only add tags, so this forbids nothing anyone is doing. It exists because the +# hook runs unattended and a mistake here is expensive, not because removing +# `unread` is wrong in principle: an "archive anything in notify/* older than +# 90 days" rule is a reasonable thing to want and would need this list revised. +# When that day comes, the question to answer first is what confirms the rule +# before it runs, not whether the guard is annoying. +# NOT the same list as mailctl.py's PROTECTED_REMOVALS, and the two must not be +# merged. That one is `{inbox}` and is a GATE: a human can override it with +# --confirm-destructive. This one is `{unread, inbox}` and is a REFUSAL, because +# there is no human present to confirm anything when cron runs a sync. +PROTECTED_REMOVALS = frozenset({"unread", "inbox"}) + + +def log(message): + print(f"post-new: {message}", file=sys.stderr) + + +def strip_inbox_from_sent(run): + """Take `inbox` off mail the user SENT, and nothing else. + + `notmuch new` applies new.tags to every file it indexes, and it cannot + tell an arrival from the copy qtmaildir files into a sent folder after a + send. The result is sent mail carrying `inbox`, which puts it in an inbox + view it never arrived in and in any hand-typed `tag:inbox` search. + + This is NOT a relaxation of PROTECTED_REMOVALS below, and the difference + is the whole reason it can run unattended. That guard is about a RULE + removing `inbox` from mail whose provenance the hook cannot judge. Here + the provenance is the file's own path: a message inside a configured sent + folder is one this system sent, and `inbox` was never true of it. Nothing + the user could act on is being hidden. + + Only `inbox`. `unread` is untouched, because maildir.synchronize_flags is + true and removing it rewrites Maildir filenames, which reaches the server + on the next mbsync. + + Scoped to tag:new like every rule, so a sync never rewrites tags across + the whole corpus. Mail already indexed keeps whatever it has. + """ + folders = qtmaildirconf.sent_folders() + if not folders: + # No config, or no account keeping sent mail locally. Nothing to + # protect, and this must NOT fall through to an empty query: notmuch + # reads that as "match everything", which would strip `inbox` from + # every newly indexed message on the system. + return True + + query = f"{SCOPE} and ({qtmaildirconf.sent_query(folders)})" + if not run(["-inbox"], query): + return False + + log(f"sent-folder carve-out applied over {len(folders)} folder(s)") + return True + + +def protected_removals(rule): + """The protected tags this rule would remove, if any.""" + return sorted(PROTECTED_REMOVALS.intersection(rule.remove)) + + +def run_tag(arguments, query): + result = subprocess.run(["notmuch", "tag"] + arguments + ["--", query], + capture_output=True, text=True) + if result.returncode != 0: + log(f"notmuch tag failed: {result.stderr.strip()}") + return False + return True + + +def main(): + store = mailrules.load() + + # A file that will not load must NOT reach the consumer below. If the + # marker were cleared while the rules did not run, that mail could never + # be tagged by these rules again: the failure is silent, permanent, and + # invisible until someone notices a gap months later. Leaving tag:new in + # place makes the next successful run catch up instead. + if store.failed: + for warning in store.warnings: + log(warning) + log("rules did not load; leaving tag:new in place") + return 1 + + if store.missing: + log("no rules file; nothing to do") + return 0 + + # A dropped rule is not fatal, but it must be visible: this goes to the + # sync log, which is where someone looks when a tag stops appearing. + for warning in store.warnings: + log(warning) + + applied = 0 + for rule in mailrules.ordered(store.rules): + # Skip the rule, do not abort the run. A single over-reaching rule + # must not cost the tagging every other rule would have done, and + # aborting here would also leave tag:new set forever: the rule would + # be refused again on every subsequent sync and the marker would never + # be consumed. + refused = protected_removals(rule) + if refused: + log(f"rule '{rule.id}' would remove {', '.join(refused)}; " + f"skipped, this hook does not remove those unattended") + continue + + query = mailrules.scoped_query(rule, SCOPE) + if not run_tag(mailrules.tag_arguments(rule), query): + log(f"rule '{rule.id}' failed; leaving tag:new in place") + return 1 + applied += 1 + + # AFTER the rules and BEFORE the marker is consumed. After, so a rule can + # still see its own sent mail with `inbox` on it and match the way it + # always did; before, because the marker is what scopes this to newly + # indexed mail and consuming it first would leave nothing to match. + if not strip_inbox_from_sent(run_tag): + log("sent-folder carve-out failed; leaving tag:new in place") + return 1 + + # Only after every rule succeeded. A failure part way through leaves the + # marker set, so re-running the hook is safe and finishes the work. + if not run_tag(["-new"], SCOPE): + return 1 + + log(f"applied {applied} rule(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/assets/hooks/qtmaildirconf.py b/assets/hooks/qtmaildirconf.py new file mode 100755 index 0000000..e709a28 --- /dev/null +++ b/assets/hooks/qtmaildirconf.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# +# 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. +"""Reads the account layout out of qtmaildir.conf, for the post-new hook. + +Only the sent folders are read, and only so the hook can tell mail the user +SENT from mail that arrived. Everything else in that file belongs to the +application. + +Stdlib only: this is imported by a notmuch hook that runs on every sync. + +The file is written by QSettings rather than by configparser, and the two +disagree in one place that matters here. QSettings treats `/` in a section +name as a group separator, so accounts are `[account.]` with a DOT, and +that key may itself contain dots (`[account.provider.name]`). The account key +is therefore everything after the FIRST dot, never a split on the last one. +""" + +import configparser +from pathlib import Path + +ACCOUNT_PREFIX = "account." + + +def default_path(): + """~/.config/qtmaildir/qtmaildir.conf, honouring XDG_CONFIG_HOME. + + Read through the environment rather than hardcoded so a test can point + at a throwaway config, which is how the hook's own tests reach it. + """ + import os + base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config") + return Path(base) / "qtmaildir" / "qtmaildir.conf" + + +def _accounts(path): + """Every `[account.*]` section as a dict, or nothing at all. + + A file that will not parse yields NO accounts rather than raising. The + caller is a hook running after `notmuch new` has already indexed the + mail: failing the sync over a malformed application config is worse than + not protecting sent mail for one cycle, and the hook logs the miss. + """ + parser = configparser.ConfigParser( + # QSettings writes `;` comments, and `#` appears inside values (a + # colour is `#2f6fa8`), so `#` must NOT introduce a comment. + comment_prefixes=(";",), + # A value may contain `%` and `$`; neither is an interpolation here. + interpolation=None, + # `[Gmail]/Posta inviata` is a legal value. Nothing in this file + # relies on duplicate keys, but tolerating them beats raising. + strict=False) + try: + # Explicit UTF-8: QSettings writes it, and the C locale would + # otherwise decide. + with open(path, encoding="utf-8") as handle: + parser.read_file(handle) + except (OSError, UnicodeDecodeError, configparser.Error): + return [] + + return [(name[len(ACCOUNT_PREFIX):], parser[name]) + for name in parser.sections() + if name.startswith(ACCOUNT_PREFIX)] + + +# Folders mail does not ARRIVE in: this system put the message there itself. +# +# Trash is deliberately absent. qtmaildir's own Delete leaves `inbox` on a +# trashed message so Restore can put it back where it came from, and stripping +# it here would fight that. +NOT_ARRIVALS = ("sent", "drafts") + + +def sent_folders(path=None): + """Every folder mail does not arrive in, relative to the mail root. + + An account contributes nothing unless it names a maildir: a bare `Sent` + would match every account's folder of that name at once. Each of the keys + in NOT_ARRIVALS is optional on its own, since an account may keep no sent + mail or no drafts locally. + """ + if path is None: + path = default_path() + + folders = [] + for _key, section in _accounts(path): + maildir = section.get("maildir", "").strip() + if not maildir: + continue + for key in NOT_ARRIVALS: + folder = section.get(key, "").strip() + if folder: + folders.append(f"{maildir}/{folder}") + return folders + + +def sent_query(folders): + """A notmuch query matching everything inside the given folders. + + Empty for an empty list, and the caller MUST check: an empty query means + "match everything" to notmuch, so handing this straight to a tag command + would treat the whole corpus as sent mail. + + `path:` is hierarchical, so `/**` covers `cur/` and `new/` and + any nesting a provider invents underneath. + """ + if not folders: + return "" + + terms = [f'path:"{_quote(folder)}/**"' for folder in folders] + return " or ".join(terms) + + +def _quote(value): + """Escape a folder name for a double-quoted notmuch term. + + Backslashes BEFORE quotes: the other order escapes the backslashes just + added. Same rule as SearchTerm::quote() in the application, and the same + reason. + """ + return value.replace("\\", "\\\\").replace('"', '\\"') diff --git a/assets/hooks/test_mailrules.py b/assets/hooks/test_mailrules.py new file mode 100755 index 0000000..b1f31c9 --- /dev/null +++ b/assets/hooks/test_mailrules.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# +# 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. +"""Self-checks for mailrules.py, the shared tagging-rule store. + +The risk in this file is the format, not the notmuch calls: a rule that +silently loses a field on save, or one that sorts into the wrong stage, +mis-tags real mail on the next sync and does it quietly. + +Run: ./test_mailrules.py +""" + +import json +import tempfile +from pathlib import Path + +import mailrules + + +def write_rules(tmp, payload): + path = Path(tmp) / "rules.json" + path.write_text(json.dumps(payload)) + return path + + +def test_loads_a_rule(): + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 1, + "rules": [ + { + "id": "notify-forge", + "stage": 50, + "enabled": True, + "add": ["notify/forge"], + "remove": [], + "query": "from:notifications@example.com", + "note": "All repositories, not one project.", + } + ], + }) + store = mailrules.load(path) + assert store.warnings == [], store.warnings + assert len(store.rules) == 1 + rule = store.rules[0] + assert rule.id == "notify-forge" + assert rule.stage == 50 + assert rule.enabled is True + assert rule.add == ["notify/forge"] + assert rule.remove == [] + assert rule.query == "from:notifications@example.com" + assert rule.note == "All repositories, not one project." + + +def test_defaults_are_applied(): + """stage, enabled, remove and note are all optional in the file.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 1, + "rules": [{"id": "minimal", "add": ["x"], + "query": "from:someone@example.com"}], + }) + store = mailrules.load(path) + assert store.warnings == [], store.warnings + rule = store.rules[0] + assert rule.stage == 50 + assert rule.enabled is True + assert rule.remove == [] + assert rule.note == "" + + +def test_a_bad_rule_is_dropped_and_the_rest_survive(): + """One malformed rule must not stop the others. The hook runs every ten + minutes on real mail; losing all tagging because of one typo is worse + than losing one rule.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 1, + "rules": [ + {"id": "good", "add": ["x"], "query": "from:a@example.com"}, + {"id": "no-query", "add": ["y"]}, + {"id": "no-tags", "query": "from:b@example.com"}, + {"id": "bad id!", "add": ["z"], "query": "from:c@example.com"}, + {"add": ["w"], "query": "from:d@example.com"}, + ], + }) + store = mailrules.load(path) + assert [r.id for r in store.rules] == ["good"] + assert len(store.warnings) == 4, store.warnings + joined = " ".join(store.warnings) + assert "no-query" in joined + assert "no-tags" in joined + assert "bad id!" in joined + + +def test_duplicate_ids_keep_the_first(): + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 1, + "rules": [ + {"id": "dup", "add": ["first"], "query": "from:a@example.com"}, + {"id": "dup", "add": ["second"], "query": "from:b@example.com"}, + ], + }) + store = mailrules.load(path) + assert len(store.rules) == 1 + assert store.rules[0].add == ["first"] + assert any("dup" in w for w in store.warnings) + + +def test_a_missing_file_is_empty_not_an_error(): + """qtmaildir must open on a machine that has never written this file.""" + with tempfile.TemporaryDirectory() as tmp: + store = mailrules.load(Path(tmp) / "absent.json") + assert store.rules == [] + assert store.warnings == [] + assert store.missing is True + + +def test_unparseable_json_warns_and_yields_no_rules(): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "rules.json" + path.write_text("{not json") + store = mailrules.load(path) + assert store.rules == [] + assert len(store.warnings) == 1 + assert store.failed is True + + +def test_a_newer_format_version_is_refused(): + """Guessing at semantics a later version defined is how a rule silently + changes meaning. Refuse instead.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 2, + "rules": [{"id": "x", "add": ["a"], "query": "from:a@example.com"}], + }) + store = mailrules.load(path) + assert store.rules == [] + assert store.failed is True + assert any("version" in w for w in store.warnings) + + +def test_ordered_sorts_by_stage_then_file_position(): + """Account tags must run before topic rules. Ties keep file order, so + the file still reads as a sequence.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 1, + "rules": [ + {"id": "topic-b", "stage": 50, "add": ["b"], + "query": "from:b@example.com"}, + {"id": "account", "stage": 10, "add": ["acct"], + "query": "path:\"work/**\""}, + {"id": "topic-a", "stage": 50, "add": ["a"], + "query": "from:a@example.com"}, + ], + }) + store = mailrules.load(path) + assert [r.id for r in mailrules.ordered(store.rules)] == [ + "account", "topic-b", "topic-a"] + + +def test_ordered_skips_disabled_rules(): + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 1, + "rules": [ + {"id": "on", "add": ["a"], "query": "from:a@example.com"}, + {"id": "off", "add": ["b"], "query": "from:b@example.com", + "enabled": False}, + ], + }) + store = mailrules.load(path) + assert [r.id for r in mailrules.ordered(store.rules)] == ["on"] + # The disabled rule is still LOADED, so a UI can show and re-enable it. + assert [r.id for r in store.rules] == ["on", "off"] + + +def test_save_round_trips_unknown_fields(): + """The neutrality guarantee. If this tool strips a field qtmaildir + added, the file is this tool's file that qtmaildir may read.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 1, + "future_top_level": {"set_by": "another tool"}, + "rules": [{ + "id": "keeper", + "add": ["x"], + "query": "from:a@example.com", + "future_field": [1, 2, 3], + }], + }) + store = mailrules.load(path) + assert store.rules[0].unknown == {"future_field": [1, 2, 3]} + + mailrules.save(store, path) + + raw = json.loads(path.read_text()) + assert raw["future_top_level"] == {"set_by": "another tool"} + assert raw["rules"][0]["future_field"] == [1, 2, 3] + assert raw["rules"][0]["id"] == "keeper" + assert raw["version"] == 1 + + +def test_save_is_atomic(): + """A reader must never see a half-written file: the hook runs every ten + minutes and a truncated read would be a failed sync.""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "rules.json" + store = mailrules.Store(rules=[ + mailrules.Rule(id="a", query="from:a@example.com", add=["x"])]) + mailrules.save(store, path) + # The temp file the write went through must not be left behind. + assert [p.name for p in Path(tmp).iterdir()] == ["rules.json"] + assert json.loads(path.read_text())["rules"][0]["id"] == "a" + + +def test_save_creates_the_directory(): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "nested" / "rules.json" + mailrules.save(mailrules.Store(), path) + assert path.exists() + assert json.loads(path.read_text()) == {"version": 1, "rules": []} + + +def test_scoped_query_parenthesises_the_rule(): + """Without the parentheses `tag:new and a or b` binds as + `(tag:new and a) or b`, and the rule matches every message in the corpus + satisfying b rather than only new arrivals. Several real rules are a + disjunction of senders, so this is the difference between tagging four + messages and tagging four thousand.""" + rule = mailrules.Rule( + id="disjunction", + query="from:a@example.com or from:b@example.com", + add=["promo"]) + assert mailrules.scoped_query(rule, "tag:new") == ( + "tag:new and (from:a@example.com or from:b@example.com)") + + +def test_scoped_query_with_no_scope_is_the_bare_query(): + """A dry run counts against the whole corpus, which is what makes the + same rule answer 'what would this tag on arrival' and 'what does this + match in all my mail'.""" + rule = mailrules.Rule(id="x", query="from:a@example.com", add=["y"]) + assert mailrules.scoped_query(rule, None) == "from:a@example.com" + assert mailrules.scoped_query(rule, "") == "from:a@example.com" + + +def test_tag_arguments(): + rule = mailrules.Rule(id="x", query="from:a@example.com", + add=["one", "two"], remove=["three"]) + assert mailrules.tag_arguments(rule) == ["+one", "+two", "-three"] + + +def run_all(): + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print(f"ok {name}") + + +if __name__ == "__main__": + run_all() + print("\nall passed") diff --git a/assets/hooks/test_post_new.py b/assets/hooks/test_post_new.py new file mode 100755 index 0000000..a0228aa --- /dev/null +++ b/assets/hooks/test_post_new.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +# +# 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. +"""End-to-end checks for the post-new hook against a throwaway notmuch +database. Nothing here touches the user's real mail: NOTMUCH_CONFIG points at +a generated maildir under a temp directory. + +The properties worth proving are the ones that cannot be unit-tested from +mailrules.py alone: + + - a rule actually tags the mail its query matches, and only that mail + - the tag:new marker is consumed on success + - the marker SURVIVES when a rule fails, so a re-run catches up + - a rule removing a protected tag is skipped whole, and the run continues + +Run: ./test_post_new.py (requires notmuch on PATH) +""" + +import json +import os +import subprocess +import tempfile +from pathlib import Path + +HOOK = Path(__file__).resolve().parent / "post-new" + + +def make_message(maildir, name, sender, subject): + path = maildir / "new" / name + path.write_text( + f"From: {sender}\n" + f"To: you@example.org\n" + f"Subject: {subject}\n" + f"Message-Id: <{name}@example.org>\n" + f"Date: Mon, 11 Aug 2026 10:00:00 +0000\n" + f"\nbody\n") + + +def setup_database(tmp): + """A maildir with three messages, indexed, every message carrying the + `new` marker the rules key off.""" + maildir = Path(tmp) / "Mail" + for sub in ("new", "cur", "tmp"): + (maildir / sub).mkdir(parents=True) + + make_message(maildir, "one", "notifications@example.com", "a notification") + make_message(maildir, "two", "friend@example.org", "a real message") + make_message(maildir, "three", "promo@example.net", "an advertisement") + + config = Path(tmp) / "notmuch-config" + config.write_text( + f"[database]\npath={maildir}\n\n" + f"[new]\ntags=new;unread;inbox\n\n" + f"[user]\nname=Test\nprimary_email=you@example.org\n") + + env = dict(os.environ) + env["NOTMUCH_CONFIG"] = str(config) + env["XDG_CONFIG_HOME"] = str(Path(tmp) / "config") + subprocess.run(["notmuch", "new"], env=env, capture_output=True, check=True) + return env + + +def write_rules(env, rules): + path = Path(env["XDG_CONFIG_HOME"]) / "mailrules" / "rules.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"version": 1, "rules": rules})) + + +def count(env, query): + out = subprocess.run(["notmuch", "count", query], env=env, + capture_output=True, text=True, check=True) + return int(out.stdout.strip()) + + +def test_a_rule_tags_only_what_it_matches(): + with tempfile.TemporaryDirectory() as tmp: + env = setup_database(tmp) + write_rules(env, [{ + "id": "notify", + "add": ["notify/forge"], + "query": "from:notifications@example.com", + }]) + assert count(env, "tag:new") == 3 + + result = subprocess.run([str(HOOK)], env=env, capture_output=True, + text=True) + assert result.returncode == 0, result.stderr + + assert count(env, "tag:notify/forge") == 1 + assert count(env, "tag:notify/forge and from:friend@example.org") == 0 + # The marker is consumed, so the next sync's rules see only new mail. + assert count(env, "tag:new") == 0 + + +def test_stage_order_is_honoured(): + with tempfile.TemporaryDirectory() as tmp: + env = setup_database(tmp) + write_rules(env, [ + {"id": "late", "stage": 50, "add": ["second"], + "query": "tag:first"}, + {"id": "early", "stage": 10, "add": ["first"], + "query": "from:notifications@example.com"}, + ]) + subprocess.run([str(HOOK)], env=env, capture_output=True, check=True) + # `late` matches only what `early` tagged, so a wrong order gives 0. + assert count(env, "tag:second") == 1 + + +def test_a_failing_rule_leaves_the_marker_in_place(): + """The property that makes a re-run safe. An invalid query fails the + notmuch call, and tag:new must survive so the next run catches up. + + The query has to be one notmuch genuinely rejects, which is a narrower + set than it looks: notmuch 0.39's parser accepts unbalanced parentheses + and bare punctuation without complaint, tags nothing, and exits 0. A + malformed date range is rejected by the date parser and does exit + non-zero, which is why the fixture uses one. + """ + with tempfile.TemporaryDirectory() as tmp: + env = setup_database(tmp) + write_rules(env, [{ + "id": "broken", + "add": ["x"], + "query": "date:zzz..zzz", + }]) + result = subprocess.run([str(HOOK)], env=env, capture_output=True, + text=True) + assert result.returncode == 1 + assert count(env, "tag:new") == 3 + + +def test_a_disjunction_stays_inside_its_scope(): + """The parenthesisation guard, end to end. Both senders are already + indexed and out of tag:new after a first run; a rule that escaped its + scope would tag them anyway.""" + with tempfile.TemporaryDirectory() as tmp: + env = setup_database(tmp) + write_rules(env, [{"id": "noop", "add": ["pass-one"], + "query": "from:nobody@example.invalid"}]) + subprocess.run([str(HOOK)], env=env, capture_output=True, check=True) + assert count(env, "tag:new") == 0 + + write_rules(env, [{ + "id": "disjunction", + "add": ["promo"], + "query": "from:friend@example.org or from:promo@example.net", + }]) + subprocess.run([str(HOOK)], env=env, capture_output=True, check=True) + # Nothing carries tag:new any more, so a correctly scoped rule tags + # nothing. Unparenthesised, the `or` branch would tag one message. + assert count(env, "tag:promo") == 0 + + +def test_a_protected_removal_is_skipped_whole_and_the_run_continues(): + """The PROTECTED_REMOVALS guard, end to end. + + Four assertions in one run, because three of them pass against a guard + that is broken in a different way. A guard that skipped only the removal + would still apply the rule's adds; a guard that aborted the run would + starve every later rule; and a guard that aborted before the consumer + would strand tag:new, so every future sync would refuse the same rule + again and nothing would ever be tagged after it. + """ + with tempfile.TemporaryDirectory() as tmp: + env = setup_database(tmp) + write_rules(env, [ + {"id": "over-reaching", "stage": 10, + "add": ["archived"], "remove": ["unread", "inbox"], + "query": "from:notifications@example.com"}, + {"id": "well-behaved", "stage": 20, "add": ["promo"], + "query": "from:promo@example.net"}, + ]) + assert count(env, "tag:unread") == 3 + assert count(env, "tag:inbox") == 3 + + result = subprocess.run([str(HOOK)], env=env, capture_output=True, + text=True) + assert result.returncode == 0, result.stderr + assert "over-reaching" in result.stderr, result.stderr + + # 1. the protected tags survive on the message the rule matched + assert count(env, "tag:unread and from:notifications@example.com") == 1 + assert count(env, "tag:inbox and from:notifications@example.com") == 1 + # 2. the rule is skipped ENTIRELY, so its adds never land either + assert count(env, "tag:archived") == 0 + # 3. a later, well-behaved rule still runs + assert count(env, "tag:promo") == 1 + # 4. the marker is still consumed, so the next sync is not stuck + assert count(env, "tag:new") == 0 + + +def setup_accounts(tmp, sent_config=True): + """A maildir laid out as qtmaildir configures it: two accounts, each with + an Inbox and a Sent folder, one message in each. + + Separate from setup_database() because the sent carve-out is the only + thing that cares where a file sits. The folder names are the awkward + ones deliberately: a bracketed, spaced provider folder is what the real + config carries, and a flat `Sent` is what the other half carries. + """ + root = Path(tmp) / "Mail" + folders = { + "one": ("acct-one/Inbox", "acct-one/Sent"), + "two": ("acct-two/[Provider]/Posta inviata", + "acct-two/[Provider]/Posta inviata"), + } + for sub in ("acct-one/Inbox", "acct-one/Sent", + "acct-two/Inbox", "acct-two/[Provider]/Posta inviata"): + for part in ("new", "cur", "tmp"): + (root / sub / part).mkdir(parents=True) + + make_message(root / "acct-one/Inbox", "arrived-one", + "friend@example.org", "an arrival") + make_message(root / "acct-one/Sent", "sent-one", + "you@example.org", "something sent") + make_message(root / "acct-two/Inbox", "arrived-two", + "friend@example.org", "another arrival") + make_message(root / "acct-two/[Provider]/Posta inviata", "sent-two", + "you@example.org", "something else sent") + + config = Path(tmp) / "notmuch-config" + config.write_text( + f"[database]\npath={root}\n\n" + f"[new]\ntags=new;unread;inbox\n\n" + f"[user]\nname=Test\nprimary_email=you@example.org\n") + + env = dict(os.environ) + env["NOTMUCH_CONFIG"] = str(config) + env["XDG_CONFIG_HOME"] = str(Path(tmp) / "config") + + if sent_config: + conf = Path(env["XDG_CONFIG_HOME"]) / "qtmaildir" / "qtmaildir.conf" + conf.parent.mkdir(parents=True, exist_ok=True) + conf.write_text( + "[account.one]\nmaildir = acct-one\nsent = Sent\n" + "[account.two]\nmaildir = acct-two\n" + "sent = [Provider]/Posta inviata\n") + + subprocess.run(["notmuch", "new"], env=env, capture_output=True, + check=True) + return env + + +def test_sent_mail_does_not_keep_the_inbox_tag(): + """The carve-out. notmuch's new.tags applies `inbox` to every file it + indexes, including the copy the composer files into a sent folder, so + mail the user SENT shows up in an inbox view it never arrived in. + + Both accounts are asserted, because the folder shapes differ and a + reader that mishandles the bracketed, spaced one would still pass on the + flat `Sent`. + """ + with tempfile.TemporaryDirectory() as tmp: + env = setup_accounts(tmp) + write_rules(env, []) + assert count(env, "tag:inbox") == 4 + + result = subprocess.run([str(HOOK)], env=env, capture_output=True, + text=True) + assert result.returncode == 0, result.stderr + + # The two sent copies lose it... + assert count(env, 'tag:inbox and path:"acct-one/Sent/**"') == 0 + assert count( + env, + 'tag:inbox and path:"acct-two/[Provider]/Posta inviata/**"') == 0 + # ...and the two arrivals keep it. This is the half that fails if the + # query is unscoped, which is the expensive mistake here. + assert count(env, "tag:inbox") == 2 + assert count(env, 'tag:inbox and path:"acct-one/Inbox/**"') == 1 + assert count(env, 'tag:inbox and path:"acct-two/Inbox/**"') == 1 + + +def test_sent_mail_keeps_every_other_tag(): + """Only `inbox` is stripped. `unread` in particular must survive: + maildir.synchronize_flags is true, so removing it rewrites Maildir + filenames and reaches the server on the next mbsync. + """ + with tempfile.TemporaryDirectory() as tmp: + env = setup_accounts(tmp) + write_rules(env, []) + subprocess.run([str(HOOK)], env=env, capture_output=True, check=True) + assert count(env, 'tag:unread and path:"acct-one/Sent/**"') == 1 + + +def test_the_carve_out_only_touches_newly_indexed_mail(): + """Scoped to tag:new like every rule, so the hook never rewrites tags + across the whole corpus on a sync. A sent message whose `inbox` tag was + put back by hand stays that way until it is reindexed. + """ + with tempfile.TemporaryDirectory() as tmp: + env = setup_accounts(tmp) + write_rules(env, []) + subprocess.run([str(HOOK)], env=env, capture_output=True, check=True) + assert count(env, 'tag:inbox and path:"acct-one/Sent/**"') == 0 + + subprocess.run(["notmuch", "tag", "+inbox", "--", + 'path:"acct-one/Sent/**"'], env=env, check=True) + subprocess.run([str(HOOK)], env=env, capture_output=True, check=True) + assert count(env, 'tag:inbox and path:"acct-one/Sent/**"') == 1 + + +def test_no_qtmaildir_config_leaves_every_tag_alone(): + """The hook must run on a system with no qtmaildir config: it then + protects nothing rather than failing the sync, and above all does not + treat an empty folder list as "every path", which is what an empty + notmuch query means. + """ + with tempfile.TemporaryDirectory() as tmp: + env = setup_accounts(tmp, sent_config=False) + write_rules(env, []) + result = subprocess.run([str(HOOK)], env=env, capture_output=True, + text=True) + assert result.returncode == 0, result.stderr + assert count(env, "tag:inbox") == 4 + + +def run_all(): + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + fn() + print(f"ok {name}") + + +if __name__ == "__main__": + run_all() + print("\nall passed") diff --git a/assets/hooks/test_qtmaildirconf.py b/assets/hooks/test_qtmaildirconf.py new file mode 100755 index 0000000..c8aa78d --- /dev/null +++ b/assets/hooks/test_qtmaildirconf.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# +# 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. +"""Unit checks for the qtmaildir.conf reader the post-new hook uses to find +the sent folders. + +The file is written by QSettings, not by configparser, so the cases that +matter are the ones where the two disagree: a section name carrying a dot, a +comment introduced by `;`, and a key present but empty. + +Run: ./test_qtmaildirconf.py +""" + +import tempfile +from pathlib import Path + +import qtmaildirconf + + +def write_config(tmp, text): + path = Path(tmp) / "qtmaildir" / "qtmaildir.conf" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + +def test_sent_folders_are_read_per_account(): + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.work]\n" + "maildir = work\n" + "sent = Sent\n" + "trash = Trash\n") + assert qtmaildirconf.sent_folders(path) == ["work/Sent"] + + +def test_drafts_are_excluded_alongside_sent(): + """A draft never arrived either, so it must not carry `inbox`. Both keys + feed one list: the hook asks a single question, "is this a folder mail + arrives in", and sent and drafts answer it the same way. + + Trash is deliberately NOT here. qtmaildir's own Delete leaves `inbox` on + a trashed message so Restore can put it back where it came from, and + stripping it here would fight that. + """ + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.work]\n" + "maildir = work\n" + "sent = Sent\n" + "drafts = Drafts\n" + "trash = Trash\n") + assert qtmaildirconf.sent_folders(path) == ["work/Sent", "work/Drafts"] + + +def test_an_account_with_only_drafts_still_contributes(): + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.a]\nmaildir = a\ndrafts = Drafts\n") + assert qtmaildirconf.sent_folders(path) == ["a/Drafts"] + + +def test_an_account_section_may_carry_a_dot(): + """QSettings writes `[account.a.b]` for the key `a.b`, and the account + key is everything after the first dot. Splitting on the LAST dot names + an account that does not exist and finds no folder.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.provider.name]\n" + "maildir = provider-name\n" + "sent = Sent\n") + assert qtmaildirconf.sent_folders(path) == ["provider-name/Sent"] + + +def test_a_folder_may_contain_spaces_and_brackets(): + """`[Gmail]/Posta inviata` is a real folder name here. The brackets are + the provider's, not INI syntax, because they are in a VALUE.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.g]\n" + "maildir = gmail\n" + "sent = [Gmail]/Posta inviata\n") + assert qtmaildirconf.sent_folders(path) == [ + "gmail/[Gmail]/Posta inviata"] + + +def test_an_account_without_a_sent_key_contributes_nothing(): + """`sent` is optional: an account may keep no sent mail locally. It must + not contribute an entry, since a bare `maildir/` prefix would match the + whole account.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.a]\nmaildir = a\ntrash = Trash\n" + "[account.b]\nmaildir = b\nsent = Sent\n") + assert qtmaildirconf.sent_folders(path) == ["b/Sent"] + + +def test_an_empty_sent_value_contributes_nothing(): + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.a]\nmaildir = a\nsent =\n") + assert qtmaildirconf.sent_folders(path) == [] + + +def test_an_account_without_a_maildir_contributes_nothing(): + """Without the account's own subdirectory the folder cannot be located, + and a bare `Sent` would match every account's sent folder at once.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "[account.a]\nsent = Sent\n") + assert qtmaildirconf.sent_folders(path) == [] + + +def test_comments_and_other_sections_are_ignored(): + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "; a comment\n" + "[general]\n" + "language = it\n" + "[sync]\n" + "command = /bin/true\n" + "[account.a]\n" + "; another comment\n" + "maildir = a\n" + "sent = Sent\n") + assert qtmaildirconf.sent_folders(path) == ["a/Sent"] + + +def test_a_missing_file_yields_no_folders(): + """The hook must run on a system with no qtmaildir config at all: it + then protects nothing, rather than failing the sync.""" + with tempfile.TemporaryDirectory() as tmp: + assert qtmaildirconf.sent_folders(Path(tmp) / "absent.conf") == [] + + +def test_an_unreadable_file_yields_no_folders(): + """A malformed config must not fail the sync. notmuch new has already + run at this point; refusing to tag is worse than not protecting sent + mail for one cycle.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_config(tmp, "this is not an ini file\n[[[\n") + assert qtmaildirconf.sent_folders(path) == [] + + +def test_the_query_scopes_every_folder(): + folders = ["a/Sent", "g/[Gmail]/Posta inviata"] + query = qtmaildirconf.sent_query(folders) + assert query == ('path:"a/Sent/**" or path:"g/[Gmail]/Posta inviata/**"') + + +def test_the_query_is_empty_when_no_folder_is_configured(): + """An empty query means "match everything" to notmuch, so the caller + must be able to tell "nothing to protect" from "protect the world".""" + assert qtmaildirconf.sent_query([]) == "" + + +def test_a_folder_containing_a_quote_cannot_break_out_of_the_query(): + """The folder name reaches a notmuch query as a quoted string. A stray + double quote would end the term and let the rest be read as syntax.""" + query = qtmaildirconf.sent_query(['a/He said "hi"']) + assert query.count('"') % 2 == 0 + assert "\\\"" in query or '""' in query + + +def main(): + tests = [value for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value)] + for test in tests: + test() + print(f"ok {test.__name__}") + print(f"\n{len(tests)} passed") + + +if __name__ == "__main__": + main() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fc19b01..1af49bb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -82,3 +82,23 @@ add_qtmaildir_test(translations) # only as English in a running Italian UI. target_compile_definitions(test_translations PRIVATE TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations") + +# The notmuch hooks (assets/hooks/), which are Python rather than C++ and are +# therefore registered directly rather than through add_qtmaildir_test(). +# +# They run against the user's REAL mail on every sync, so they belong in the +# suite rather than beside it as scripts someone remembers to run. Two of the +# three need `notmuch` on PATH and build a throwaway database in a temp +# directory; none of them touches the real one. +# +# No QT_QPA_PLATFORM here: nothing Qt is involved. +find_package(Python3 COMPONENTS Interpreter) +if(Python3_Interpreter_FOUND) + foreach(hook_test mailrules post_new qtmaildirconf) + add_test(NAME hooks_${hook_test} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/assets/hooks/test_${hook_test}.py) + endforeach() +else() + message(STATUS "Python3 not found: the notmuch hook tests will not run") +endif() -- cgit v1.2.3