summaryrefslogtreecommitdiffstats
path: root/tests/test_mainwindow.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_mainwindow.cpp')
-rw-r--r--tests/test_mainwindow.cpp2342
1 files changed, 2338 insertions, 4 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 4d70a29..98dae12 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -48,8 +48,16 @@
#include "keymap.h"
#include "mainwindow.h"
#include "messageview.h"
+#include "mimeparser.h"
#include "notmuchworker.h"
#include "carddelegate.h"
+#include "composewindow.h"
+#include "senddialog.h"
+#include "messagesender.h"
+#include <QCheckBox>
+#include <QPlainTextEdit>
+#include <QPointer>
+#include <QListWidget>
#include "cardlayout.h"
#include <QImage>
@@ -96,6 +104,35 @@ public:
/// no trash key either. A caller that names an account and wants Delete to
/// work has to say where its trash is, which is the same requirement the
/// real config imposes.
+ /// One [account.<key>] section to write.
+ ///
+ /// `sendCommand` is what makes the account able to send, and its EMPTINESS
+ /// is what makes it receive-only: the capability is the key's presence,
+ /// not a separate flag, so a receive-only account is written by omitting
+ /// it exactly as the real config expresses it.
+ struct AccountSpec
+ {
+ QString key;
+ QString maildir;
+ QString trash;
+ QString sendCommand;
+ QString address;
+ };
+
+ /// Writes several accounts, for the compose cases.
+ ///
+ /// Beside build() rather than replacing it: every existing caller passes
+ /// at most one account and none of them needs a send command, so widening
+ /// the three-argument signature further would make ten call sites carry
+ /// two empty strings each for one test's benefit.
+ bool buildWithAccounts(const QList<AccountSpec> &accounts,
+ const QString &composeKey = QString())
+ {
+ m_accounts = accounts;
+ m_composeKey = composeKey;
+ return build();
+ }
+
bool build(const QString &accountKey = QString(),
const QString &accountMaildir = QString(),
const QString &accountTrash = QString())
@@ -142,6 +179,21 @@ public:
// folder that does not exist would CREATE it.
out << "inbox=inbox\n";
}
+ if (!m_composeKey.isEmpty())
+ out << "\n[compose]\n" << m_composeKey << "\n";
+ for (const AccountSpec &account : m_accounts) {
+ out << "\n[account." << account.key << "]\n"
+ << "maildir=" << account.maildir << "\n"
+ << "inbox=inbox\n";
+ if (!account.trash.isEmpty())
+ out << "trash=" << account.trash << "\n";
+ if (!account.address.isEmpty())
+ out << "address=" << account.address << "\n";
+ // Written only when non-empty. An account with no
+ // send_command is receive-only, which is the shape under test.
+ if (!account.sendCommand.isEmpty())
+ out << "send_command=" << account.sendCommand << "\n";
+ }
}
file.close();
@@ -162,6 +214,8 @@ private:
QTemporaryDir m_confDir;
Config m_config;
QString m_error;
+ QList<AccountSpec> m_accounts;
+ QString m_composeKey;
};
/// MainWindow is mostly wiring. Cases that need a real database opt into one
@@ -197,6 +251,25 @@ private slots:
void narrowingAnEmptyQueryBarIsAPlainSearch();
void aMalformedAccountIsReportedWithoutBlockingTheConstructor();
void aWorkerBackedWindowReturnsRealThreads();
+
+ // Compose and send, item 123 task 12.
+ void theMailRootComesFromTheConfigNotTheIndex();
+ void replyIsDisabledOnAReceiveOnlyAccountsMail();
+ void theReceiveOnlyRibbonNamesTheAccount();
+ void replyIsEnabledOnASendingAccountsMail();
+ void composeIsDisabledOnlyWhenNoAccountCanSend();
+ void quittingWithACleanComposerAsksNothing();
+ void quittingWithUnsavedEditsReportsEveryComposer();
+ void closingAComposerCompactsTheRegistry();
+ void savingAMessageRefusesToEscapeTheChosenDirectory();
+ void aHostileSubjectCannotEscapeTheSaveDirectory();
+ void savingTwiceDoesNotOverwriteTheFirstFile();
+ void savingAMessageWithAHostileSubjectStaysInTheDirectory();
+ void aStuckComposeRequestDoesNotHijackTheNextPaneLoad();
+ void theSaveLoopToleratesAComposerClosedUnderTheDialog();
+ void quittingClosesEveryComposerRatherThanOrphaningIt();
+ void forwardingCarriesTheOriginalsAttachments();
+ void forwardSeedsHtmlFromTheConfigNotTheOriginal();
void aStartupAccountScopesTheStartupQuery();
void aStartupAccountAlsoScopesASavedStartupQuery();
void aGeneratedStartupQueryActuallyRuns();
@@ -331,6 +404,7 @@ private slots:
void everyActionCarriesAnIcon();
void everyActionIsReachableFromAMenu();
+ void noMenuHasTwoEntriesSharingAMnemonic();
void theToolbarDoesNotOverrideTheDesktopButtonStyle();
void theImportantActionIsLabelledImportant();
void theImportantActionStillWritesTheFlaggedTag();
@@ -391,6 +465,37 @@ private slots:
void theRefreshAfterARestoreLeavesUndoIntact();
void deletingOutsideTheTrashViewLeavesTheRowInPlace();
+ // ComposeWindow, item 123. These need a window but no worker: the composer
+ // never touches NotmuchWorker, it reads its context from the value struct
+ // MainWindow hands it, so a Config written to a temporary INI is the whole
+ // fixture.
+ void aComposerOpensClean();
+ void typingMarksTheComposerDirty();
+ void anAutosaveWritesADraftAndClearsTheDirtyFlag();
+ void anUnwritableDraftsFolderRaisesThePersistentBanner();
+ void aSuccessfulSaveClearsTheBanner();
+ void anAccountWithoutADraftsFolderReportsNoFailure();
+ void aRewrittenDraftUnlinksThePreviousRevision();
+ void theComposerBuildsTheMessageItsWidgetsShow();
+ void theFromDropdownDecidesWhichAccountSends();
+ void aFormatEditPreservesTheUndoStack();
+ void aFormatEditRestoresTheSelectionItAsksFor();
+ void aFormatEditOnAnEmptySelectionLandsBetweenTheTokens();
+ void theAttachmentWarningRespectsTheConfiguredThreshold();
+ void aDisabledAttachmentWarningWarnsAboutNothing();
+ void theQuotePositionDecidesWhereTheQuoteLands();
+ void theSeededQuoteIsNotAnUndoStep();
+ void aReplySeedsTheHtmlToggleFromTheOriginal();
+ void aNewMessageSeedsTheHtmlToggleFromConfig();
+ void disablingInputsCoversEveryFieldAndTheToolbar();
+ void aFailedSendCanBeRetriedWithoutFilingTheWrongCopy();
+ void anUnchangedMessageIsNotWrittenAgain();
+ void closingInsideTheDebounceStillSavesTheDraft();
+ void closingAfterASendWritesNoFurtherDraft();
+ void aCloseDuringTheCountdownIsRefused();
+ void aFailedSendKeepsTheTextThatFailedToGo();
+ void aSmallSizeLimitIsNotDescribedAsZeroMegabytes();
+
private:
/// Owns the throwaway lock table init() points every test at. A pointer
/// rather than a value because it is rebuilt per test, and QTemporaryDir
@@ -6444,6 +6549,185 @@ void TestMainWindow::everyActionIsReachableFromAMenu()
.arg(unreachable.join(QStringLiteral(", ")))));
}
+void TestMainWindow::noMenuHasTwoEntriesSharingAMnemonic()
+{
+ // The sibling of everyActionIsReachableFromAMenu(), and it exists because
+ // the rule it enforces had lived only in prose and in one other test's
+ // COMMENT, and was duly broken the first time a batch of entries was added
+ // to a menu (item 123: `&Reply` against the pre-existing `&Restore from
+ // trash`, both Alt+R).
+ //
+ // Qt does not error on a duplicate mnemonic. It CYCLES between the
+ // colliding entries instead of activating either, so the key silently
+ // stops working and merely moves a highlight. That is worse than it
+ // sounds in the Message menu, where `restore` is deliberately greyed
+ // outside the trash view: the ordinary case was pressing Alt+R and landing
+ // on a disabled entry.
+ //
+ // Item 57 already decided this is a property rather than a taste. It
+ // rejected the label "Starred" for `flag` precisely because it would have
+ // collided with `Mark &spam`, and theImportantActionIsLabelledImportant()
+ // pins the surviving label with that reasoning in its comment. A decision
+ // recorded only in prose is one nobody re-derives.
+ //
+ // Scoped PER MENU, which is what the collision actually is: a mnemonic is
+ // resolved among the entries of the menu that is open, so the same letter
+ // in File and in View is not a conflict.
+ const Config config;
+ MainWindow window(config);
+
+ auto *bar = window.menuBar();
+ QVERIFY(bar);
+
+ // The menu bar's own top-level titles are one such scope too, so the walk
+ // starts by treating the bar as a menu and then descends.
+ QList<QPair<QString, QList<QAction *>>> scopes;
+ scopes.append({ QStringLiteral("the menu bar"), bar->actions() });
+
+ QList<QMenu *> pending;
+ const auto topLevel = bar->actions();
+ for (QAction *action : topLevel) {
+ if (action->menu())
+ pending.append(action->menu());
+ }
+ QVERIFY2(!pending.isEmpty(), "the menu bar holds no menus");
+
+ while (!pending.isEmpty()) {
+ QMenu *menu = pending.takeFirst();
+ const auto entries = menu->actions();
+ scopes.append({ menu->title(), entries });
+ for (QAction *entry : entries) {
+ if (QMenu *sub = entry->menu())
+ pending.append(sub);
+ }
+ }
+
+ // The four collisions that PREDATE this test, measured by running it
+ // against the tree before item 123 touched any label. They are listed
+ // rather than fixed, and rather than being hidden by narrowing the test,
+ // because renaming a shipped menu entry is the user's call and not a
+ // test's: three of them are in menus a user has had in their fingers
+ // since 0.1.0.
+ //
+ // Listed as exact pairs, not as "ignore Alt+R", so this is a freeze and
+ // not an amnesty: a NEW entry colliding on any of these same keys still
+ // fails, because its pair is not on this list. Fixing one is then a
+ // one-line deletion here, which is the point of writing them out.
+ // Written as the FULL GROUP of labels sharing one key in one menu, not as
+ // a pair. A pair is keyed on which entry the walk happened to see first,
+ // so adding a colliding entry ABOVE a frozen one silently re-pairs it and
+ // the new defect gets reported as "a frozen collision no longer happens",
+ // which names the wrong thing entirely. Measured: reinstating `&Reply`
+ // did exactly that before this was changed. A group is order-independent,
+ // so a new entry grows the group and fails as a new collision.
+ static const QStringList knownPreExistingCollisions = {
+ QStringLiteral("&Message: Alt+R shared by \"&Restore from trash\", \"Mark all &read\", \"Tagging &rules...\""),
+ QStringLiteral("&Message: Alt+S shared by \"Mark &spam\", \"Find &stranded deleted mail\""),
+ QStringLiteral("&View: Alt+O shared by \"&Open thread\", \"Zoom &out\""),
+ };
+
+ QStringList collisions;
+ int compared = 0;
+
+ for (const auto &scope : scopes) {
+ // Keyed on the mnemonic Qt itself derives, not on a hand-parsed '&'.
+ // The question is which key Qt will dispatch, and only Qt answers it:
+ // "&&" is a literal ampersand and carries no mnemonic at all.
+ //
+ // A QMap rather than a QHash so the groups come out in a stable key
+ // order, which is what lets the frozen list above be written once and
+ // stay matching.
+ QMap<QString, QStringList> byMnemonic;
+ for (QAction *entry : scope.second) {
+ if (entry->isSeparator())
+ continue;
+ const QKeySequence mnemonic = QKeySequence::mnemonic(entry->text());
+ if (mnemonic.isEmpty())
+ continue;
+ ++compared;
+ byMnemonic[mnemonic.toString(QKeySequence::NativeText)]
+ .append(QStringLiteral("\"%1\"").arg(entry->text()));
+ }
+
+ for (auto it = byMnemonic.cbegin(); it != byMnemonic.cend(); ++it) {
+ if (it.value().size() < 2)
+ continue;
+ // Names the menu, the key and EVERY label in the group, so a
+ // future failure says what to rename without anyone going looking.
+ collisions.append(QStringLiteral("%1: %2 shared by %3")
+ .arg(scope.first, it.key(),
+ it.value().join(QStringLiteral(", "))));
+ }
+ }
+
+ // The guard, and it is not ceremonial: every assertion below is a loop
+ // that reports success when it runs zero times. A walk that found no
+ // mnemonics at all would pass this test against any label whatsoever.
+ QVERIFY2(compared > 20,
+ qPrintable(QStringLiteral("only %1 menu entries carried a "
+ "mnemonic, so this probe measured "
+ "almost nothing")
+ .arg(compared)));
+
+ // Matched on the menu and key only, with the labels compared separately
+ // below. Comparing whole strings made a GROWING group read as a frozen one
+ // disappearing: adding `&Reply` took Alt+R from three labels to four, the
+ // frozen three-label string stopped matching, and the failure said "this
+ // collision no longer happens" about the very key that had just got worse.
+ // Measured twice, once per attempt, which is why the two questions are
+ // asked separately.
+ const auto scopeAndKey = [](const QString &collision) {
+ return collision.left(collision.indexOf(QStringLiteral(" shared by ")));
+ };
+
+ QHash<QString, QString> frozen;
+ for (const QString &known : knownPreExistingCollisions)
+ frozen.insert(scopeAndKey(known), known);
+
+ QStringList unexpected;
+ QSet<QString> stillPresent;
+ for (const QString &collision : collisions) {
+ const QString key = scopeAndKey(collision);
+ const auto known = frozen.constFind(key);
+ if (known == frozen.constEnd()) {
+ // A collision on a key nothing froze: entirely new.
+ unexpected.append(collision);
+ continue;
+ }
+ stillPresent.insert(key);
+ if (*known != collision) {
+ // The key was already colliding, but the CAST has changed, which
+ // for a frozen entry means an entry joined it. Reported as the
+ // new collision it is, naming both what was frozen and what is
+ // there now.
+ unexpected.append(
+ QStringLiteral("%1 (frozen as [%2], now [%3])")
+ .arg(key, *known, collision));
+ }
+ }
+
+ // A frozen entry that has since been FIXED must not stay on the list
+ // silently, or the list becomes a place stale claims accumulate.
+ QStringList stale;
+ for (const QString &known : knownPreExistingCollisions) {
+ if (!stillPresent.contains(scopeAndKey(known)))
+ stale.append(known);
+ }
+ QVERIFY2(stale.isEmpty(),
+ qPrintable(QStringLiteral("%1 frozen collision(s) no longer "
+ "happen, so delete them from "
+ "knownPreExistingCollisions: %2")
+ .arg(stale.size())
+ .arg(stale.join(QStringLiteral("; ")))));
+
+ QVERIFY2(unexpected.isEmpty(),
+ qPrintable(QStringLiteral("%1 menu mnemonic collision(s), where "
+ "Qt cycles the highlight instead of "
+ "activating: %2")
+ .arg(unexpected.size())
+ .arg(unexpected.join(QStringLiteral("; ")))));
+}
+
void TestMainWindow::everyActionCarriesAnIcon()
{
// Item 56. The complaint was inconsistency, not absence: eight actions had
@@ -6967,15 +7251,22 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// the words saying which. Giving them five invented shapes would be less
// clear than the pairing.
//
+ // reply_no_quote joined them in item 123 for exactly the same reason: it
+ // shares reply's icon, it is a menu entry that always carries its text,
+ // and it is not on the toolbar. The list is therefore no longer only the
+ // thread tier, which is why it is named for the PROPERTY that earns the
+ // exemption rather than for the tier that first needed it.
+ //
// Named as an exception list rather than by asking the toolbar what it
// holds, so that PUTTING one of these on the toolbar fails this test
// rather than silently passing it.
- static const QStringList menuOnlyThreadActions = {
+ static const QStringList menuOnlySharedIconActions = {
QStringLiteral("archive_thread"),
QStringLiteral("delete_thread"),
QStringLiteral("spam_thread"),
QStringLiteral("toggle_unread_thread"),
QStringLiteral("flag_thread"),
+ QStringLiteral("reply_no_quote"),
};
const Config config;
@@ -6986,7 +7277,7 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// may sit on the toolbar.
auto *toolBar = window.findChild<QToolBar *>();
QVERIFY(toolBar);
- for (const QString &name : menuOnlyThreadActions) {
+ for (const QString &name : menuOnlySharedIconActions) {
auto *action = window.findChild<QAction *>(name);
QVERIFY2(action, qPrintable(QStringLiteral("no action named %1").arg(name)));
QVERIFY2(!toolBar->actions().contains(action),
@@ -7006,7 +7297,7 @@ void TestMainWindow::noTwoActionsShareAnIcon()
QVERIFY2(action, qPrintable(QStringLiteral("no action named %1").arg(name)));
if (!action->icon().isNull())
++withIcons;
- if (menuOnlyThreadActions.contains(name))
+ if (menuOnlySharedIconActions.contains(name))
continue;
if (action->icon().isNull())
continue;
@@ -7033,7 +7324,7 @@ void TestMainWindow::noTwoActionsShareAnIcon()
// And the exception list did not swallow the comparison itself.
QCOMPARE(compared, KeyMap::knownActions().size()
- - menuOnlyThreadActions.size());
+ - menuOnlySharedIconActions.size());
QVERIFY2(collisions.isEmpty(),
qPrintable(QStringLiteral("actions sharing one icon: %1")
@@ -7943,6 +8234,933 @@ void TestMainWindow::aWorkerBackedWindowReturnsRealThreads()
QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
}
+namespace {
+
+/// A worker-backed window with one message in one account's maildir.
+///
+/// The compose cases all need the same three things: a message on disk, an
+/// account owning the folder it landed in, and a selected row. Repeating that
+/// in six tests is how one of them ends up subtly different from the rest.
+struct WorkerComposeFixture
+{
+ WorkerBackedWindow backed;
+
+ /// Writes one message into <accountMaildir>/inbox and indexes it.
+ /// \p composeKey, when given, is written as one line under [compose].
+ bool seed(const QList<WorkerBackedWindow::AccountSpec> &accounts,
+ const QString &folder, const QString &composeKey = QString())
+ {
+ if (!backed.fixture().addMessage(
+ folder, QStringLiteral("compose1@example.org"),
+ QStringLiteral("A subject"),
+ QStringLiteral("sender@example.org"),
+ // Friday, verified with `date -d 2026-08-14 +%A`.
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text."))) {
+ return false;
+ }
+ return backed.buildWithAccounts(accounts, composeKey);
+ }
+
+ /// Runs a query and puts the current index on its one row.
+ ///
+ /// Waits on the MAIL ROOT as well as on the row. The reply family is gated
+ /// on which account owns the message, which needs the root, and that
+ /// arrives on its own queued signal: asserting on an action's enabled
+ /// state before it lands measures the startup race rather than the rule.
+ static bool selectTheMessage(MainWindow &window)
+ {
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ if (!model || !view || !queryEdit)
+ return false;
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+
+ bool ready = false;
+ for (int attempt = 0; attempt < 150 && !ready; ++attempt) {
+ ready = model->rowCount(QModelIndex()) == 1
+ && !window.mailRootForTesting().isEmpty();
+ if (!ready)
+ QTest::qWait(100);
+ }
+ if (!ready)
+ return false;
+
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ return true;
+ }
+};
+
+} // namespace
+
+void TestMainWindow::theMailRootComesFromTheConfigNotTheIndex()
+{
+ // Item 124's rule, for the path the composer composes drafts and sent
+ // copies under. splitIndex() is what makes this test able to fail at all:
+ // in the ordinary layout notmuch_database_get_path() and
+ // NOTMUCH_CONFIG_MAIL_ROOT return the SAME string, so a test written
+ // against it passes whichever accessor the code uses.
+ WorkerComposeFixture fixture;
+ fixture.backed.fixture().splitIndex();
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ // The MAIL root, not the index directory. Under the split layout these are
+ // different directories, and a draft composed under the index one is
+ // written into the Xapian tree.
+ QCOMPARE(window.mailRootForTesting(),
+ QDir(fixture.backed.fixture().maildirPath()).absolutePath());
+ QVERIFY2(window.mailRootForTesting()
+ != QDir(fixture.backed.fixture().indexPath()).absolutePath(),
+ "the window took the index directory for the mail root");
+}
+
+void TestMainWindow::replyIsDisabledOnAReceiveOnlyAccountsMail()
+{
+ // The capability IS the send_command's presence, so this account is
+ // written without one.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(),
+ /*sendCommand=*/QString(),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ for (const QString &name : { QStringLiteral("reply"),
+ QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward") }) {
+ auto *action = window.findChild<QAction *>(name);
+ QVERIFY2(action, qPrintable(QStringLiteral("no action %1").arg(name)));
+ QVERIFY2(!action->isEnabled(),
+ qPrintable(QStringLiteral("%1 was live on receive-only mail")
+ .arg(name)));
+ }
+
+ // save_message is NEVER disabled, including here. It is the escape hatch
+ // for exactly this case: write the raw message out and attach it to a new
+ // message from an account that can send.
+ auto *save = window.findChild<QAction *>(QStringLiteral("save_message"));
+ QVERIFY(save);
+ QVERIFY2(save->isEnabled(),
+ "save_message was disabled, removing the escape hatch");
+}
+
+void TestMainWindow::replyIsEnabledOnASendingAccountsMail()
+{
+ // The guard for the test above. Without it, a bug disabling the reply
+ // family unconditionally would pass every assertion there while removing
+ // the feature entirely.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ for (const QString &name : { QStringLiteral("reply"),
+ QStringLiteral("reply_all"),
+ QStringLiteral("reply_no_quote"),
+ QStringLiteral("forward") }) {
+ auto *action = window.findChild<QAction *>(name);
+ QVERIFY2(action, qPrintable(QStringLiteral("no action %1").arg(name)));
+ QVERIFY2(action->isEnabled(),
+ qPrintable(QStringLiteral("%1 was disabled on mail from an "
+ "account that can send").arg(name)));
+ }
+
+ // And no ribbon: this account can send, so there is nothing to explain.
+ auto *ribbon =
+ window.findChild<QLabel *>(QStringLiteral("receiveOnlyRibbon"));
+ QVERIFY(ribbon);
+ QVERIFY2(ribbon->isHidden(),
+ "the receive-only ribbon showed on an account that can send");
+}
+
+void TestMainWindow::theReceiveOnlyRibbonNamesTheAccount()
+{
+ // The ribbon is a WIDGET in MessageView's layout, not markup inside the
+ // web view. Composing HTML from configuration into the one document that
+ // renders input from strangers is the wrong direction.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(),
+ QString(), QStringLiteral("you@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ auto *ribbon =
+ window.findChild<QLabel *>(QStringLiteral("receiveOnlyRibbon"));
+ QVERIFY2(ribbon, "no ribbon widget exists");
+
+ // isHidden() rather than isVisibleTo(): under the offscreen platform an
+ // unshown window's children report not visible whatever the code does, so
+ // isVisibleTo would fail against correct code. What is being asserted is
+ // that the ribbon was not left explicitly hidden.
+ QVERIFY2(!ribbon->isHidden(),
+ "the ribbon did not appear on receive-only mail");
+ QVERIFY2(ribbon->text().contains(QStringLiteral("listsonly")),
+ qPrintable(QStringLiteral("the ribbon does not name the account: %1")
+ .arg(ribbon->text())));
+
+ // PlainText, not AutoText. A QLabel guesses under AutoText, and this is
+ // the same protection MessageDetailsDialog states on every value.
+ QCOMPARE(ribbon->textFormat(), Qt::PlainText);
+}
+
+void TestMainWindow::composeIsDisabledOnlyWhenNoAccountCanSend()
+{
+ // An installation with no send_command anywhere is a valid read-only
+ // installation and is not warned about; compose is simply unavailable.
+ {
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(),
+ QString(), QStringLiteral("you@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ auto *compose = window.findChild<QAction *>(QStringLiteral("compose"));
+ QVERIFY(compose);
+ QVERIFY2(!compose->isEnabled(),
+ "compose was live with no account able to send");
+ }
+ {
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed(
+ { { QStringLiteral("listsonly"),
+ QStringLiteral("listsonly"), QString(), QString(),
+ QStringLiteral("you@example.org") },
+ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("work@example.org") } },
+ QStringLiteral("listsonly/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ auto *compose = window.findChild<QAction *>(QStringLiteral("compose"));
+ QVERIFY(compose);
+ QVERIFY2(compose->isEnabled(),
+ "compose was disabled although one account can send");
+ }
+}
+
+void TestMainWindow::quittingWithACleanComposerAsksNothing()
+{
+ // Case 1: every composer clean, quit directly, no dialog. A dialog here
+ // would be the "are you sure" this project deliberately does not do.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ QVERIFY2(window.openComposerForTest(), "no composer opened");
+ QCOMPARE(window.openComposerCount(), 1);
+
+ QVERIFY2(window.composersBlockingQuit().isEmpty(),
+ "a clean composer was reported as blocking quit");
+
+ // Composers are parentless top-level windows and outlive this MainWindow,
+ // carrying a MessageSender and a running autosave timer into whatever test
+ // runs next. Closed here rather than left for the destructor, which never
+ // touches m_composers.
+ for (ComposeWindow *composer : window.openComposersForTest()) {
+ composer->show();
+ composer->close();
+ }
+}
+
+void TestMainWindow::quittingWithUnsavedEditsReportsEveryComposer()
+{
+ // Case 2: ONE dialog whatever the count, so the quit path has to see BOTH
+ // composers rather than stopping at the first dirty one.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ QVERIFY(window.openComposerForTest());
+ QVERIFY(window.openComposerForTest());
+ QCOMPARE(window.openComposerCount(), 2);
+
+ // Clean until something is typed, which is the case-1 assertion holding
+ // here too and the guard that this test can distinguish the two states.
+ QVERIFY(window.composersBlockingQuit().isEmpty());
+
+ window.markComposersDirtyForTest();
+ QCOMPARE(window.composersBlockingQuit().size(), 2);
+
+ // Left open, these are parentless top-level windows with a live autosave
+ // timer, surviving into later tests. See the note in the clean-composer
+ // case above.
+ for (ComposeWindow *composer : window.openComposersForTest()) {
+ composer->show();
+ composer->close();
+ }
+}
+
+void TestMainWindow::closingAComposerCompactsTheRegistry()
+{
+ // The closed() signal's ONE job. The QPointer alone would keep
+ // composersBlockingQuit() correct, since it nulls on destruction, but the
+ // entry would stay in the list for the session's lifetime. This asserts
+ // the list is compacted, which only the signal can do.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ ComposeWindow *composer = window.openComposerForTest();
+ QVERIFY(composer);
+ QCOMPARE(window.openComposerCount(), 1);
+
+ // A composer that was never shown returns early from close() WITHOUT
+ // reaching closeEvent(), so the signal would never fire and this test
+ // would assert nothing at all.
+ composer->show();
+ QVERIFY(composer->close());
+
+ // And the quit path must not see a destroyed window, which is the
+ // QPointer's job rather than the signal's.
+ QCOMPARE(window.openComposerCount(), 0);
+ QVERIFY(window.composersBlockingQuit().isEmpty());
+}
+
+void TestMainWindow::savingAMessageRefusesToEscapeTheChosenDirectory()
+{
+ // A subject is input from a stranger and is what the default filename is
+ // derived from, so it may carry separators and "..". Asserted through
+ // Attachment's own helpers, which is what saveDisplayedMessage() calls:
+ // a second implementation of the check here would prove nothing about the
+ // one that runs.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString directory = dir.path();
+
+ Attachment naming;
+ naming.filename = QStringLiteral("../../etc/passwd");
+ const QString target =
+ QDir(directory).absoluteFilePath(naming.safeFilename());
+
+ QVERIFY2(Attachment::isPathInsideDirectory(directory, target),
+ "a traversing subject escaped the chosen directory");
+ QVERIFY2(!target.contains(QStringLiteral("/etc/passwd")),
+ qPrintable(QStringLiteral("the traversal survived: %1").arg(target)));
+
+ // Compared as PATHS, never with startsWith(): a sibling directory whose
+ // name merely begins with the chosen one's is not inside it.
+ QVERIFY2(!Attachment::isPathInsideDirectory(
+ directory, directory + QStringLiteral("-evil/message.eml")),
+ "a sibling directory passed the containment check");
+}
+
+void TestMainWindow::aHostileSubjectCannotEscapeTheSaveDirectory()
+{
+ // Asserted through MainWindow::defaultMessageFilename(), which is what
+ // saveDisplayedMessage() actually calls. The previous version of this
+ // check built an Attachment by hand and called safeFilename() directly:
+ // that proves what Attachment does and nothing about whether save_message
+ // asks it anything, and three mutations to the real path left it green.
+ // CLAUDE.md: assert through the function the production path calls, not
+ // through the one it calls INTO.
+ const QString traversal =
+ MainWindow::defaultMessageFilename(QStringLiteral("../../etc/passwd"));
+
+ // No separator survives, so the name cannot address another directory.
+ QVERIFY2(!traversal.contains(QLatin1Char('/')),
+ qPrintable(QStringLiteral("a separator survived: %1").arg(traversal)));
+ // NOT asserting the absence of "..": with every separator replaced, a
+ // literal ".." inside a filename addresses nothing and is a legitimate
+ // part of a name. What matters is that the result is a single path
+ // COMPONENT, which is what makes traversal impossible.
+ QCOMPARE(QFileInfo(traversal).fileName(), traversal);
+ QVERIFY2(traversal != QStringLiteral("..")
+ && traversal != QStringLiteral("."),
+ qPrintable(QStringLiteral("the name is a directory reference: %1")
+ .arg(traversal)));
+
+ // And joining it onto a directory really does stay inside.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Attachment naming;
+ naming.filename = traversal;
+ const QString target =
+ QDir(dir.path()).absoluteFilePath(naming.safeFilename());
+ QVERIFY2(Attachment::isPathInsideDirectory(dir.path(), target),
+ qPrintable(QStringLiteral("escaped the directory: %1").arg(target)));
+
+ // A backslash is a separator too, on a name written by Windows software.
+ const QString backslash = MainWindow::defaultMessageFilename(
+ QStringLiteral("..\\..\\Windows\\System32\\config"));
+ QVERIFY2(!backslash.contains(QLatin1Char('\\')),
+ qPrintable(QStringLiteral("a backslash survived: %1").arg(backslash)));
+
+ // A subject with nothing usable still yields a name rather than "" or a
+ // bare extension, which would make the write land on a dotfile.
+ const QString empty = MainWindow::defaultMessageFilename(QString());
+ QVERIFY2(empty.startsWith(QStringLiteral("message")),
+ qPrintable(QStringLiteral("empty subject gave: %1").arg(empty)));
+
+ // The extension survives truncation. Truncating AFTER appending it would
+ // cut ".eml" off a long subject and write an extensionless file.
+ const QString long_ = MainWindow::defaultMessageFilename(
+ QString(400, QLatin1Char('a')));
+ QVERIFY2(long_.endsWith(QStringLiteral(".eml")),
+ qPrintable(QStringLiteral("the extension was truncated away: %1")
+ .arg(long_.right(20))));
+}
+
+void TestMainWindow::savingTwiceDoesNotOverwriteTheFirstFile()
+{
+ // Two messages very often share a subject, and the filename is derived
+ // from it, so the second save must not destroy the first. Driven through
+ // saveDisplayedMessage() by way of the directory seam, which is the only
+ // way to reach the write guard at all: the file dialog is a modal the
+ // offscreen platform cannot click.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ QTemporaryDir out;
+ QVERIFY(out.isValid());
+
+ window.saveDisplayedMessageForTest(out.path());
+ window.saveDisplayedMessageForTest(out.path());
+
+ // Two files, not one overwritten. Asserted on the COUNT rather than on the
+ // second name, so the disambiguation scheme can change without the test
+ // caring what it is called.
+ const QStringList written =
+ QDir(out.path()).entryList(QDir::Files | QDir::NoDotAndDotDot);
+ QCOMPARE(written.size(), 2);
+
+ // And both are real copies rather than one empty placeholder.
+ for (const QString &name : written) {
+ QVERIFY2(QFileInfo(QDir(out.path()).absoluteFilePath(name)).size() > 0,
+ qPrintable(QStringLiteral("%1 is empty").arg(name)));
+ }
+}
+
+void TestMainWindow::savingAMessageWithAHostileSubjectStaysInTheDirectory()
+{
+ // Driven through saveDisplayedMessage() with a real hostile subject, which
+ // is the only shape that covers the production write path. An earlier
+ // version of this coverage built an Attachment by hand and called
+ // safeFilename() and isPathInsideDirectory() directly, which proves what
+ // Attachment does and nothing about whether save_message asks it anything.
+ //
+ // WHAT THIS CAN AND CANNOT CATCH, measured rather than assumed, because
+ // the numbers are surprising and the next person will otherwise redo the
+ // work. Three independent layers stand between a subject and the write:
+ // defaultMessageFilename() replaces separators, Attachment::safeFilename()
+ // reduces to a basename, and Attachment::isPathInsideDirectory() refuses
+ // the write. EACH ONE ALONE IS SUFFICIENT, so removing any single layer
+ // leaves this test green: measured, all three single-layer mutations pass.
+ // Removing all three fails it. That is real defence-in-depth rather than a
+ // probe pointed at the wrong object, and mimeparser.h:71-77 already says
+ // the same of isPathInsideDirectory, but it does mean this test is a guard
+ // against the DEFENCES COLLECTIVELY disappearing, not a guard on any one
+ // of them. aHostileSubjectCannotEscapeTheSaveDirectory() covers the first
+ // layer on its own, and a single-layer mutation there does fail.
+ //
+ // The subject is ABSOLUTE rather than "../..", and that matters.
+ // QDir::absoluteFilePath() does not resolve ".." (measured: it
+ // concatenates), but the collision loop below can rename a relative
+ // traversal by accident when the target happens to exist, which makes it
+ // the weaker probe. An absolute candidate replaces the directory outright.
+ WorkerComposeFixture fixture;
+ QVERIFY(fixture.backed.fixture().addMessage(
+ QStringLiteral("work/inbox"), QStringLiteral("hostile@example.org"),
+ // The subject is the attacker's input, and it is what the default
+ // filename is derived from.
+ // Absolute, not "../..". QDir::absoluteFilePath() does NOT resolve
+ // ".." (measured: it concatenates, giving "<dir>/../../x"), but an
+ // ABSOLUTE candidate replaces the directory outright, which is the
+ // escape that survives every accident. A relative traversal can be
+ // neutralised by the collision loop renaming it when the target
+ // happens to exist, so it is the weaker probe of the two.
+ QStringLiteral("/tmp/qtmaildir-pwned-probe"),
+ QStringLiteral("sender@example.org"),
+ // Friday, verified with `date -d 2026-08-14 +%A`.
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY2(fixture.backed.buildWithAccounts(
+ { { QStringLiteral("work"), QStringLiteral("work"), QString(),
+ QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } }),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ // A directory INSIDE another, so an escape has somewhere to land that the
+ // test can then look at. Escaping "out" writes into parent/, which is what
+ // the assertions below check is still empty.
+ QTemporaryDir parent;
+ QVERIFY(parent.isValid());
+ const QString out = parent.filePath(QStringLiteral("out"));
+ QVERIFY(QDir().mkpath(out));
+
+ window.saveDisplayedMessageForTest(out);
+
+ // The file landed inside the chosen directory.
+ // NOT QDir::Hidden. A file whose name begins with a dot is hidden on every
+ // Unix desktop, so the write would succeed while the user could not find
+ // what they saved. Listing without Hidden is what makes this assertion
+ // notice that, and it is how the leading-dot case was found: a traversing
+ // subject reduces to "..-..-etc-passwd" once its separators are replaced,
+ // which is a dotfile.
+ const QStringList inside =
+ QDir(out).entryList(QDir::Files | QDir::NoDotAndDotDot);
+ QCOMPARE(inside.size(), 1);
+ QVERIFY2(!inside.first().startsWith(QLatin1Char('.')),
+ qPrintable(QStringLiteral("the saved message is hidden: %1")
+ .arg(inside.first())));
+
+ // And nothing was written beside it, which is where a traversal would go.
+ const QStringList escaped =
+ QDir(parent.path()).entryList(QDir::Files | QDir::NoDotAndDotDot);
+ QVERIFY2(escaped.isEmpty(),
+ qPrintable(QStringLiteral("a file escaped the directory: %1")
+ .arg(escaped.join(QLatin1Char(' ')))));
+
+ // The written path really is contained, compared as PATHS rather than with
+ // startsWith(): a sibling directory whose name merely begins with the
+ // chosen one's is not inside it.
+ const QString written = QDir(out).absoluteFilePath(inside.first());
+ QVERIFY2(Attachment::isPathInsideDirectory(out, written),
+ qPrintable(QStringLiteral("escaped: %1").arg(written)));
+ QVERIFY2(QFileInfo(written).size() > 0, "the saved message is empty");
+}
+
+void TestMainWindow::aStuckComposeRequestDoesNotHijackTheNextPaneLoad()
+{
+ // A compose request for a message that is not in the index used to stay
+ // armed for ever, because it was cleared only on the branch that FOUND the
+ // id. The delayed symptom is the bad one: the pane's own loads are the
+ // traffic being matched against, so merely selecting that message later
+ // matched, opened a composer nobody asked for, and returned before
+ // renderMessages() leaving the pane blank on the row just clicked.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ // Arm a request for an id the database does not hold. loadMessage() emits
+ // an empty result for it, which is what must disarm the request.
+ window.requestMessageForComposeForTest(
+ QStringLiteral("nosuchmessage@example.org"),
+ ComposeContext::Kind::Reply, true);
+
+ // No composer, and the request stops being armed.
+ QTRY_VERIFY_WITH_TIMEOUT(!window.composeRequestPendingForTest(), 15000);
+ QCOMPARE(window.openComposerCount(), 0);
+
+ // Now the delayed half. Select the real message: the pane must render it,
+ // and no composer may appear. With the request still armed this failed
+ // only if the ids matched, so the request is re-armed for the REAL id to
+ // make the hijack reachable at all.
+ window.requestMessageForComposeForTest(
+ QStringLiteral("compose1@example.org"), ComposeContext::Kind::Reply,
+ true);
+ QTRY_VERIFY_WITH_TIMEOUT(!window.composeRequestPendingForTest(), 15000);
+
+ // That one DID match, so it opened a composer. Close it and clear the
+ // pane, then re-select and assert the pane renders rather than a second
+ // composer opening.
+ for (ComposeWindow *composer : window.openComposersForTest()) {
+ composer->show();
+ composer->close();
+ }
+ QCOMPARE(window.openComposerCount(), 0);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(model && view);
+ view->setCurrentIndex(QModelIndex());
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->showingPlaceholder(), 15000);
+ QCOMPARE(window.openComposerCount(), 0);
+}
+
+void TestMainWindow::quittingClosesEveryComposerRatherThanOrphaningIt()
+{
+ // A composer is a parentless top-level window, deliberately: it must appear
+ // in the task switcher and be usable while the main window is. The cost is
+ // that closing the main window does NOT take it down, so quitting left a
+ // composer on screen with no application behind it, and Qt kept the process
+ // alive for it. Reported from a hand test: the main window closed, the
+ // orphan stayed, and its own close then raised the unsaved-edits dialog for
+ // a session the user had already ended.
+ //
+ // The quit path already ASKS about those edits and saves them; what it
+ // never did was close the windows afterwards.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ // Two, so the fix cannot be "close the last one" and pass.
+ QVERIFY2(window.openComposerForTest(), "no composer opened");
+ QVERIFY2(window.openComposerForTest(), "no second composer opened");
+ QCOMPARE(window.openComposerCount(), 2);
+
+ // Clean composers: the point here is the CLOSE, not the unsaved-edits
+ // dialog, which has its own tests and would block this one on a modal.
+ window.show();
+ window.close();
+
+ // deleteLater() is how a composer goes away, so the count settles on the
+ // next event-loop pass rather than synchronously.
+ QTRY_COMPARE_WITH_TIMEOUT(window.openComposerCount(), 0, 5000);
+}
+
+void TestMainWindow::theSaveLoopToleratesAComposerClosedUnderTheDialog()
+{
+ // The regression for a measured use-after-free. composersBlockingQuit()
+ // used to return raw pointers, and the quit path held that list across
+ // QMessageBox::exec(). A nested event loop PROCESSES deleteLater(),
+ // verified in a standalone Qt program: a parentless WA_DeleteOnClose
+ // window closed while a modal is up is destroyed BEFORE exec() returns.
+ // The dialog is window-modal to the main window only, so a user really can
+ // close a composer from under it, and Save then ran on freed memory.
+ //
+ // The modal itself cannot be driven under the offscreen platform, so what
+ // is asserted is the property that makes the loop safe: the list holds
+ // QPointers, and an entry whose window is destroyed reads as null rather
+ // than as a dangling pointer. That is exactly what the null check in the
+ // Save loop consumes. Stated plainly because it is NOT full coverage of
+ // closeEvent(): see the report.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+
+ QVERIFY(window.openComposerForTest());
+ QVERIFY(window.openComposerForTest());
+ window.markComposersDirtyForTest();
+
+ QList<QPointer<ComposeWindow>> blocking = window.composersBlockingQuit();
+ QCOMPARE(blocking.size(), 2);
+
+ // Destroy one exactly as closing it under the dialog would, including the
+ // deleteLater() a nested exec() would process.
+ ComposeWindow *doomed = blocking.first().data();
+ QVERIFY(doomed);
+ doomed->show();
+ doomed->close();
+ QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
+
+ // The held list reports it as gone rather than handing back a dangling
+ // pointer. A raw QList<ComposeWindow *> could not express this at all.
+ QVERIFY2(blocking.first().isNull(),
+ "the held entry did not null when its window was destroyed");
+ QVERIFY2(!blocking.last().isNull(),
+ "the surviving composer was lost too");
+
+ // And the loop the quit path runs skips the null and still saves the
+ // survivor, which is the behaviour the crash destroyed: the remaining
+ // drafts were never written because the crash happened mid-loop.
+ int saved = 0;
+ for (const QPointer<ComposeWindow> &composer : blocking) {
+ if (composer) {
+ composer->saveDraftNow();
+ ++saved;
+ }
+ }
+ QCOMPARE(saved, 1);
+}
+
+namespace {
+
+/// Writes a multipart/mixed message with one named attachment part.
+///
+/// Hand-written rather than built with MessageBuilder: this is the INPUT to
+/// the forward path, and generating it with the same library that consumes it
+/// would let an encoding mistake agree with itself.
+bool writeMessageWithAttachment(const QString &path, const QString &attachName,
+ const QByteArray &attachBody)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return false;
+ QByteArray raw =
+ "From: sender@example.org\n"
+ "To: you@example.org\n"
+ "Subject: Quarterly report\n"
+ "Message-ID: <fwd-1@example.org>\n"
+ // Friday, verified with `date -d 2026-08-14 +%A`. Qt::RFC2822Date
+ // validates the weekday against the date.
+ "Date: Fri, 14 Aug 2026 10:00:00 +0200\n"
+ "MIME-Version: 1.0\n"
+ "Content-Type: multipart/mixed; boundary=\"MIX\"\n"
+ "\n"
+ "--MIX\n"
+ "Content-Type: text/plain; charset=utf-8\n"
+ "\n"
+ "See the attached document.\n"
+ "--MIX\n"
+ "Content-Type: application/octet-stream; name=\"" + attachName.toUtf8() + "\"\n"
+ "Content-Disposition: attachment; filename=\"" + attachName.toUtf8() + "\"\n"
+ "\n" + attachBody + "\n"
+ "--MIX--\n";
+ file.write(raw);
+ file.close();
+ return true;
+}
+
+/// Writes a multipart/alternative message that DOES carry a text/html part.
+bool writeHtmlMessage(const QString &path)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly))
+ return false;
+ file.write(
+ "From: sender@example.org\n"
+ "To: you@example.org\n"
+ "Subject: Has HTML\n"
+ "Message-ID: <html-1@example.org>\n"
+ "Date: Fri, 14 Aug 2026 10:00:00 +0200\n"
+ "MIME-Version: 1.0\n"
+ "Content-Type: multipart/alternative; boundary=\"ALT\"\n"
+ "\n"
+ "--ALT\n"
+ "Content-Type: text/plain; charset=utf-8\n"
+ "\n"
+ "plain\n"
+ "--ALT\n"
+ "Content-Type: text/html; charset=utf-8\n"
+ "\n"
+ "<p>html</p>\n"
+ "--ALT--\n");
+ file.close();
+ return true;
+}
+
+} // namespace
+
+void TestMainWindow::forwardingCarriesTheOriginalsAttachments()
+{
+ // The spec requires Forward to carry attachments, twice. The context field
+ // existed and was never assigned, so a Forward opened with an empty
+ // attachment list: the composer looked entirely correct, and the recipient
+ // received a body quoting a document that was not attached, with nothing
+ // erroring anywhere.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString original = dir.filePath(QStringLiteral("original.eml"));
+ QVERIFY(writeMessageWithAttachment(original, QStringLiteral("report.pdf"),
+ QByteArray("PDFBYTES")));
+
+ QTemporaryDir confDir;
+ QVERIFY(confDir.isValid());
+ const QString confPath = confDir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QSettings settings(confPath, QSettings::IniFormat);
+ settings.beginGroup(QStringLiteral("account.work"));
+ settings.setValue(QStringLiteral("maildir"), QStringLiteral("work"));
+ settings.setValue(QStringLiteral("address"),
+ QStringLiteral("you@example.org"));
+ settings.setValue(QStringLiteral("send_command"),
+ QStringLiteral("/bin/true"));
+ settings.endGroup();
+ settings.sync();
+ }
+ Config config;
+ config.load(confPath);
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::Forward;
+ context.accountKey = QStringLiteral("work");
+ context.originalPath = original;
+ context.subject = QStringLiteral("Fwd: Quarterly report");
+
+ ComposeWindow composer(context, config, dir.path());
+
+ // The attachment is present, and it is a REAL FILE on disk rather than a
+ // remembered name: MessageBuilder reads every attachment by path at build
+ // time and refuses a build naming one that does not exist.
+ const QStringList attached = composer.attachments();
+ QCOMPARE(attached.size(), 1);
+ QVERIFY2(QFileInfo::exists(attached.first()),
+ qPrintable(QStringLiteral("the extracted path does not exist: %1")
+ .arg(attached.first())));
+ QCOMPARE(QFileInfo(attached.first()).fileName(),
+ QStringLiteral("report.pdf"));
+
+ // And the bytes are the original's, not an empty placeholder.
+ QFile written(attached.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ QCOMPARE(written.readAll(), QByteArray("PDFBYTES"));
+ written.close();
+
+ // A Reply to the same message carries NOTHING. The spec says attachments
+ // are carried "for Forward, empty otherwise", and a reply that re-attached
+ // the original's documents would send them back to their own sender.
+ ComposeContext replyContext = context;
+ replyContext.kind = ComposeContext::Kind::Reply;
+ ComposeWindow replyComposer(replyContext, config, dir.path());
+ QVERIFY2(replyComposer.attachments().isEmpty(),
+ "a reply carried the original's attachments");
+}
+
+void TestMainWindow::forwardSeedsHtmlFromTheConfigNotTheOriginal()
+{
+ // MEASURED, and it revises what the spec review reported. Forward was
+ // NEVER seeding from the original: ComposeWindow::seedFields() already
+ // implements the split itself (composewindow.cpp, `isReply ?
+ // m_context.seedHtml : m_config.compose().sendHtml`), so the context's
+ // value is IGNORED for a forward and the config won regardless. The
+ // openComposerFor() line this test also covers was therefore cosmetic
+ // rather than a live defect: it stopped the context carrying a value that
+ // nothing read, which is worth doing but changed no behaviour.
+ //
+ // The consequence for this test: EITHER layer alone enforces the rule, so
+ // neither single-layer mutation fails it, and only mutating both does.
+ // Verified in both directions rather than assumed.
+ //
+ // The spec splits these: New and Forward seed from [compose] send_html,
+ // Reply and Reply-all from whether the original carried a text/html part.
+ // An HTML part in the original is a fact about the SENDER's software, so
+ // it is the right seed when answering them and says nothing about a
+ // forward, which is a new message to somebody else.
+ //
+ // Asserted on the CONTEXT the window is built from rather than through the
+ // checkbox, because what is under test is which source the value comes
+ // from. The two sources must DISAGREE or the test passes either way: the
+ // config says false while the original is plain text, so reading the
+ // original would give false as well. Hence send_html=true against a plain
+ // original: config true, original false.
+ // The two sources must DISAGREE or the test passes whichever one is read,
+ // and getting that wrong is why an earlier version of this survived every
+ // mutation: config send_html=FALSE against an original that DOES carry a
+ // text/html part. Reading the original gives true, reading the config
+ // gives false, so the assertion below can only be satisfied one way.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("work"), QStringLiteral("work"),
+ QString(), QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org") } },
+ QStringLiteral("work/inbox"),
+ QStringLiteral("send_html=false")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QTRY_VERIFY_WITH_TIMEOUT(!window.mailRootForTesting().isEmpty(), 15000);
+ QCOMPARE(fixture.backed.config().compose().sendHtml, false);
+
+ // The original lives inside the account's maildir so accountForReply()
+ // can resolve it; its CONTENT is what matters, not that notmuch indexed it.
+ const QString original =
+ QDir(window.mailRootForTesting())
+ .absoluteFilePath(QStringLiteral("work/inbox/cur/fwd-original"));
+ QVERIFY(writeHtmlMessage(original));
+
+ MimeParser parser;
+ const ParsedMessage parsed = parser.parse(original);
+ QVERIFY(parsed.ok);
+ QCOMPARE(parsed.hasHtml(), true);
+
+ // Through openComposerFor(), which is the production line that chooses
+ // the source. Building the context by hand here and asserting on the
+ // checkbox proved only that ComposeWindow honours what it is given: the
+ // mutation putting `original.hasHtml()` back stayed green, because the
+ // test was setting seedHtml itself.
+ MessageRef ref;
+ ref.messageId = QStringLiteral("html-1@example.org");
+ ref.filePath = original;
+ ref.matched = true;
+
+ window.openComposerForTest(ref, ComposeContext::Kind::Forward, true);
+
+ QList<ComposeWindow *> opened = window.openComposersForTest();
+ QCOMPARE(opened.size(), 1);
+ auto *sendHtml =
+ opened.first()->findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(sendHtml);
+ QVERIFY2(!sendHtml->isChecked(),
+ "Forward seeded sendHtml from the original's HTML part rather "
+ "than from [compose] send_html");
+
+ // The counterpart, and it is what stops this asserting "always false":
+ // a REPLY to the same message seeds from the original, so it is checked
+ // where the forward is not. Without this half, disabling the checkbox
+ // outright would pass.
+ window.openComposerForTest(ref, ComposeContext::Kind::Reply, true);
+ const QList<ComposeWindow *> both = window.openComposersForTest();
+ QCOMPARE(both.size(), 2);
+ auto *replyHtml =
+ both.last()->findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(replyHtml);
+ QVERIFY2(replyHtml->isChecked(),
+ "Reply did not seed sendHtml from the original's HTML part");
+
+ for (ComposeWindow *composer : both) {
+ composer->show();
+ composer->close();
+ }
+}
+
void TestMainWindow::aStartupAccountScopesTheStartupQuery()
{
// "Start me in Work - Inbox rather than All accounts - Inbox." The account
@@ -10583,4 +11801,1120 @@ void TestMainWindow::deletingOutsideTheTrashViewLeavesTheRowInPlace()
QCOMPARE(model->rowCount(QModelIndex()), 1);
}
+// ---------------------------------------------------------------------------
+// ComposeWindow, item 123.
+//
+// The composer owns widgets and nothing else here does, which is why its cases
+// live in this file. What is asserted is deliberately NOT what it looks like:
+// the autosave dirty check, the banner state, the message its widgets produce,
+// the format edits and the seeding rules, all of which are observable without
+// a painter. CLAUDE.md's "Rendering probes lie" section covers why counting
+// pixels here would prove nothing.
+// ---------------------------------------------------------------------------
+
+namespace {
+
+/// A Config written to a temporary INI, plus a Maildir root to write into.
+///
+/// No notmuch database and no worker: the composer never touches
+/// NotmuchWorker, so building one would only cost every case a `notmuch new`.
+/// The mail root is passed to ComposeWindow explicitly, exactly as MainWindow
+/// passes what the worker reported (item 124: it is NOT database.path).
+class ComposeFixture
+{
+public:
+ /// `drafts` and `sent` are written only when non-empty, so a test can
+ /// build the account-without-a-drafts-folder case by passing an empty
+ /// string rather than by needing a second fixture.
+ /// `secondAccount` writes a SECOND sending account, which is what makes
+ /// the From dropdown have something to choose between. Off by default:
+ /// every other case here wants exactly one, so a two-account fixture
+ /// everywhere would let a test pass by picking the only entry there is.
+ bool build(const QString &drafts = QStringLiteral("Drafts"),
+ const QString &sent = QStringLiteral("Sent"),
+ const QString &extraCompose = QString(),
+ bool secondAccount = false)
+ {
+ if (!m_confDir.isValid() || !m_mailDir.isValid())
+ return false;
+
+ const QString path = m_confDir.filePath(QStringLiteral("qtmaildir.conf"));
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
+ return false;
+ {
+ QTextStream out(&file);
+ // QSettings reads `/` in a section name as a group separator, so
+ // the section is [account.acct], never [account/acct].
+ out << "[account.acct]\n"
+ << "name=Test User\n"
+ << "address=user@example.org\n"
+ << "maildir=acct\n"
+ << "trash=Trash\n";
+ if (!drafts.isEmpty())
+ out << "drafts=" << drafts << "\n";
+ if (!sent.isEmpty())
+ out << "sent=" << sent << "\n";
+ // A command that exists and does nothing. canSend() is what the
+ // From dropdown filters on, so an account without this one line
+ // would not appear in it at all.
+ out << "send_command=/bin/true\n";
+ if (secondAccount) {
+ out << "\n[account.other]\n"
+ << "name=Other User\n"
+ << "address=other@example.org\n"
+ << "maildir=other\n"
+ << "trash=Trash\n"
+ << "drafts=Drafts\n"
+ << "sent=Sent\n"
+ << "send_command=/bin/true\n";
+ }
+ out << "\n[compose]\n";
+ if (!extraCompose.isEmpty())
+ out << extraCompose << "\n";
+ }
+ file.close();
+
+ m_config.load(path);
+ return true;
+ }
+
+ const Config &config() const { return m_config; }
+ QString mailRoot() const { return m_mailDir.path(); }
+
+ /// The account's drafts folder, as the composer will resolve it.
+ QString draftsCur() const
+ {
+ return m_mailDir.path() + QStringLiteral("/acct/Drafts/cur");
+ }
+
+ /// The second account's drafts folder.
+ QString otherDraftsCur() const
+ {
+ return m_mailDir.path() + QStringLiteral("/other/Drafts/cur");
+ }
+
+ /// How many message files sit in the drafts folder.
+ int draftCount() const
+ {
+ return QDir(draftsCur(), {}, QDir::Name, QDir::Files).count();
+ }
+
+private:
+ QTemporaryDir m_confDir;
+ QTemporaryDir m_mailDir;
+ Config m_config;
+};
+
+/// A minimal New-message context for the fixture's one account.
+ComposeContext newContext()
+{
+ ComposeContext context;
+ context.accountKey = QStringLiteral("acct");
+ context.kind = ComposeContext::Kind::New;
+ return context;
+}
+
+} // namespace
+
+void TestMainWindow::aComposerOpensClean()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // Seeding fills every field, which emits every field's change signal. A
+ // composer that counted those as edits would autosave a draft nobody
+ // asked for, and would tell the quit path there is unsaved work in a
+ // window the user opened and closed without typing.
+ QVERIFY(!window.hasUnsavedEdits());
+ QVERIFY(!window.lastSaveFailed());
+
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("autosave"));
+ QVERIFY2(timer, "no autosave timer: the window was never built");
+ QVERIFY2(!timer->isActive(),
+ "seeding armed the autosave timer, so a untouched composer writes");
+}
+
+void TestMainWindow::typingMarksTheComposerDirty()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ QVERIFY(!window.hasUnsavedEdits());
+ body->setPlainText(QStringLiteral("Some text."));
+ QVERIFY(window.hasUnsavedEdits());
+
+ // The subject is part of the message as much as the body is: a draft that
+ // saved the body but not the address it was going to would be worse than
+ // none.
+ ComposeWindow second(newContext(), fixture.config(), fixture.mailRoot());
+ auto *subject = second.findChild<QLineEdit *>(QStringLiteral("subject"));
+ QVERIFY(subject);
+ QVERIFY(!second.hasUnsavedEdits());
+ subject->setText(QStringLiteral("A subject"));
+ QVERIFY(second.hasUnsavedEdits());
+}
+
+void TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Draft body."));
+ QVERIFY(window.hasUnsavedEdits());
+
+ QVERIFY2(window.saveDraftNow(), "the draft write reported failure");
+
+ QCOMPARE(fixture.draftCount(), 1);
+ QVERIFY2(!window.hasUnsavedEdits(),
+ "the flag survived a successful save, so the quit path would ask");
+ QVERIFY(!window.lastSaveFailed());
+
+ // The bytes really are the message, not an empty file: the draft is
+ // byte-identical to what would be sent, which is the property the one
+ // built message exists for.
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("Draft body."), "the draft does not carry the body");
+ // Written with the Maildir draft flag, not left bare.
+ QVERIFY2(files.first().endsWith(QStringLiteral(":2,D")),
+ qPrintable(QStringLiteral("wrong maildir flags: ") + files.first()));
+}
+
+void TestMainWindow::anUnwritableDraftsFolderRaisesThePersistentBanner()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Draft body."));
+
+ // A FILE where the folder must go. mkpath then fails, which is a real
+ // failure mode and needs no permission games that root would defeat.
+ const QString accountDir = fixture.mailRoot() + QStringLiteral("/acct");
+ QVERIFY(QDir().mkpath(accountDir));
+ QFile blocker(accountDir + QStringLiteral("/Drafts"));
+ QVERIFY(blocker.open(QIODevice::WriteOnly));
+ blocker.write("not a directory");
+ blocker.close();
+
+ QVERIFY2(!window.saveDraftNow(), "an unwritable folder reported success");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY2(banner, "no banner widget");
+ QVERIFY2(!banner->text().isEmpty(), "the banner says nothing");
+ QVERIFY2(window.lastSaveFailed(),
+ "lastSaveFailed() is false after a failed write, so the quit "
+ "path would let the text go");
+ QVERIFY2(window.hasUnsavedEdits(),
+ "a failed save cleared the dirty flag, which claims the text is "
+ "safe on disk when it is not");
+}
+
+void TestMainWindow::aSuccessfulSaveClearsTheBanner()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("First."));
+
+ const QString accountDir = fixture.mailRoot() + QStringLiteral("/acct");
+ QVERIFY(QDir().mkpath(accountDir));
+ QFile blocker(accountDir + QStringLiteral("/Drafts"));
+ QVERIFY(blocker.open(QIODevice::WriteOnly));
+ blocker.close();
+
+ QVERIFY(!window.saveDraftNow());
+ QVERIFY(window.lastSaveFailed());
+
+ // Remove the obstruction and save again. The banner must go: a warning
+ // that stays after the thing it warned about is fixed teaches the user to
+ // ignore warnings, which is the second lesson in the TagRules entry.
+ QVERIFY(QFile::remove(accountDir + QStringLiteral("/Drafts")));
+ body->setPlainText(QStringLiteral("Second."));
+
+ QVERIFY2(window.saveDraftNow(), "the retry failed");
+ QVERIFY2(!window.lastSaveFailed(), "lastSaveFailed() stayed set");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY(banner);
+ QVERIFY2(banner->isHidden(), "the banner is still up after a good save");
+}
+
+void TestMainWindow::anAccountWithoutADraftsFolderReportsNoFailure()
+{
+ ComposeFixture fixture;
+ // No drafts key at all: a real configuration, warned about at startup.
+ QVERIFY(fixture.build(QString()));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Nowhere to save this."));
+
+ // Nothing was written and nothing failed. Reporting a failure here would
+ // make the quit path offer a retry for a state no retry can change.
+ QVERIFY2(window.saveDraftNow(),
+ "a missing drafts folder was reported as a save failure");
+ QVERIFY2(!window.lastSaveFailed(), "the banner state was set");
+
+ auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner"));
+ QVERIFY(banner);
+ QVERIFY(banner->isHidden());
+}
+
+void TestMainWindow::aRewrittenDraftUnlinksThePreviousRevision()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("Revision one."));
+ QVERIFY(window.saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ body->setPlainText(QStringLiteral("Revision two."));
+ QVERIFY(window.saveDraftNow());
+
+ // ONE file, not two. Maildir has no in-place edit, so a draft rewritten
+ // every thirty seconds would otherwise accumulate one file per pause, and
+ // every one of them is a message mbsync uploads.
+ QCOMPARE(fixture.draftCount(), 1);
+
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("Revision two."), "the surviving file is the old one");
+}
+
+void TestMainWindow::theComposerBuildsTheMessageItsWidgetsShow()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.inReplyTo = QStringLiteral("original@example.org");
+ context.references = { QStringLiteral("root@example.org"),
+ QStringLiteral("original@example.org") };
+ context.to = { QStringLiteral("one@example.org") };
+ context.subject = QStringLiteral("Re: a subject");
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+
+ auto *cc = window.findChild<QLineEdit *>(QStringLiteral("cc"));
+ auto *bcc = window.findChild<QLineEdit *>(QStringLiteral("bcc"));
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(cc && bcc && body);
+
+ // A field the user typed, split on commas. That is wrong for a RAW header
+ // and right here: this is the composer's own rendering, which joins with
+ // ", ".
+ cc->setText(QStringLiteral("two@example.org, three@example.org"));
+ bcc->setText(QStringLiteral(" four@example.org "));
+ body->setPlainText(QStringLiteral("The body."));
+
+ const OutgoingMessage message = window.currentMessage();
+ QCOMPARE(message.accountKey, QStringLiteral("acct"));
+ QCOMPARE(message.to, QStringList{ QStringLiteral("one@example.org") });
+ QCOMPARE(message.cc, (QStringList{ QStringLiteral("two@example.org"),
+ QStringLiteral("three@example.org") }));
+ // Trimmed, or the whitespace reaches the wire as part of the address.
+ QCOMPARE(message.bcc, QStringList{ QStringLiteral("four@example.org") });
+ QCOMPARE(message.subject, QStringLiteral("Re: a subject"));
+ QCOMPARE(message.markdownBody, QStringLiteral("The body."));
+
+ // NOT optional. Without them a reply appears as an orphan thread in the
+ // sender's own client, which is invisible locally.
+ QCOMPARE(message.inReplyTo, QStringLiteral("original@example.org"));
+ QCOMPARE(message.references.size(), 2);
+ QCOMPARE(message.references.last(), QStringLiteral("original@example.org"));
+}
+
+void TestMainWindow::theFromDropdownDecidesWhichAccountSends()
+{
+ // TWO sending accounts, because a dropdown with one entry cannot be
+ // changed and a test against it passes whether the code reads the dropdown
+ // or the context. The first revision of this test did exactly that: it
+ // asserted count() == 1 and then re-asserted a property another case
+ // already covers, and a mutation making currentAccount() read
+ // m_context.accountKey survived it.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QString(), /*secondAccount=*/true));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *from = window.findChild<QComboBox *>(QStringLiteral("from"));
+ QVERIFY2(from, "no From dropdown");
+
+ // Both sending accounts are offered, seeded to the context's.
+ QCOMPARE(from->count(), 2);
+ QCOMPARE(from->currentData().toString(), QStringLiteral("acct"));
+ QCOMPARE(window.currentMessage().accountKey, QStringLiteral("acct"));
+
+ // Now change it. The dropdown is the authority once the window is open:
+ // reading the context here would send from the seeded account while the
+ // interface said otherwise.
+ const int other = from->findData(QStringLiteral("other"));
+ QVERIFY2(other >= 0, "the second account is not in the dropdown");
+ from->setCurrentIndex(other);
+
+ QCOMPARE(window.currentMessage().accountKey, QStringLiteral("other"));
+
+ // And the choice reaches the DRAFT's destination, not just the value:
+ // a draft is written into the sending account's own folder, so a composer
+ // that read the context would file it under the wrong account.
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("From the other account."));
+ QVERIFY(window.saveDraftNow());
+
+ QCOMPARE(QDir(fixture.otherDraftsCur(), {}, QDir::Name, QDir::Files).count(),
+ 1u);
+ QCOMPARE(QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).count(), 0u);
+}
+
+void TestMainWindow::aFormatEditPreservesTheUndoStack()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ // Typed through a cursor, which is what makes it an undoable edit;
+ // setPlainText() would not be one.
+ QTextCursor typing = body->textCursor();
+ typing.insertText(QStringLiteral("hello"));
+ QVERIFY(body->document()->isUndoAvailable());
+
+ QTextCursor selection = body->textCursor();
+ selection.setPosition(0);
+ selection.setPosition(5, QTextCursor::KeepAnchor);
+ body->setTextCursor(selection);
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY2(bold, "no bold action");
+ bold->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("**hello**"));
+
+ // The property the plan's setPlainText() draft would have lost. Measured
+ // in a standalone probe: setPlainText() takes isUndoAvailable from true to
+ // false, so every toolbar press would throw away everything the user could
+ // undo.
+ QVERIFY2(body->document()->isUndoAvailable(),
+ "the format edit destroyed the undo stack");
+
+ // And it is ONE undo step, not one per character: a whole-document
+ // replacement inside an edit block collapses to a single entry, so one
+ // Ctrl+Z takes the tokens off and leaves the typed word.
+ body->undo();
+ QCOMPARE(body->toPlainText(), QStringLiteral("hello"));
+}
+
+void TestMainWindow::aFormatEditRestoresTheSelectionItAsksFor()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("hello world"));
+
+ // A BACKWARDS selection, anchor after the cursor, which is what a
+ // right-to-left drag produces and an ordinary gesture. Measured against a
+ // real widget: selectionStart()/selectionEnd() come back normalised even
+ // then, so the anchor's side does not reach MarkdownFormat.
+ QTextCursor selection = body->textCursor();
+ selection.setPosition(5);
+ selection.setPosition(0, QTextCursor::KeepAnchor);
+ body->setTextCursor(selection);
+ QCOMPARE(body->textCursor().selectionStart(), 0);
+ QCOMPARE(body->textCursor().selectionEnd(), 5);
+
+ auto *italic = window.findChild<QAction *>(QStringLiteral("format_italic"));
+ QVERIFY(italic);
+ italic->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("*hello* world"));
+
+ // The selection is preserved precisely so a second press can apply a
+ // SECOND token to the same words, bold then italic without reselecting.
+ QCOMPARE(body->textCursor().selectedText(), QStringLiteral("hello"));
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY(bold);
+ bold->trigger();
+ QCOMPARE(body->toPlainText(), QStringLiteral("***hello*** world"));
+}
+
+void TestMainWindow::aFormatEditOnAnEmptySelectionLandsBetweenTheTokens()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("ab"));
+
+ QTextCursor cursor = body->textCursor();
+ cursor.setPosition(1);
+ body->setTextCursor(cursor);
+
+ auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold"));
+ QVERIFY(bold);
+ bold->trigger();
+
+ QCOMPARE(body->toPlainText(), QStringLiteral("a****b"));
+
+ // The property a user notices immediately when it is wrong, and the one
+ // invisible to a test that only compares the resulting text: typing must
+ // continue INSIDE the pair, not after it.
+ QCOMPARE(body->textCursor().position(), 3);
+ QVERIFY(!body->textCursor().hasSelection());
+
+ QTextCursor typing = body->textCursor();
+ typing.insertText(QStringLiteral("x"));
+ QCOMPARE(body->toPlainText(), QStringLiteral("a**x**b"));
+}
+
+void TestMainWindow::theAttachmentWarningRespectsTheConfiguredThreshold()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("attachment_warn_bytes=1000")));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // The threshold, not the modal. The question itself needs a user, so what
+ // is asserted is the predicate that decides whether to ask.
+ QVERIFY2(!window.attachmentNeedsWarning(999), "warned below the limit");
+ QVERIFY2(!window.attachmentNeedsWarning(1000),
+ "warned AT the limit, which is not above it");
+ QVERIFY2(window.attachmentNeedsWarning(1001), "did not warn above the limit");
+}
+
+void TestMainWindow::aDisabledAttachmentWarningWarnsAboutNothing()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("attachment_warn_bytes=0")));
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+
+ // Zero means off, not "warn about everything". Read as a threshold it
+ // would question an empty file, which is the opposite of what turning a
+ // warning off means.
+ QVERIFY(!window.attachmentNeedsWarning(0));
+ QVERIFY(!window.attachmentNeedsWarning(1));
+ QVERIFY(!window.attachmentNeedsWarning(100LL * 1024 * 1024));
+}
+
+void TestMainWindow::theQuotePositionDecidesWhereTheQuoteLands()
+{
+ const QString quote = QStringLiteral("> the original");
+
+ {
+ ComposeFixture above;
+ QVERIFY(above.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("quote_position=above")));
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = quote;
+
+ ComposeWindow window(context, above.config(), above.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY2(body->toPlainText().startsWith(quote),
+ "quote_position=above did not put the quote first");
+ }
+
+ {
+ ComposeFixture below;
+ QVERIFY(below.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("quote_position=below")));
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = quote;
+
+ ComposeWindow window(context, below.config(), below.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY2(body->toPlainText().endsWith(quote),
+ "quote_position=below did not put the quote last");
+ QVERIFY2(!body->toPlainText().startsWith(quote),
+ "the quote is at the top under quote_position=below");
+ }
+}
+
+void TestMainWindow::theSeededQuoteIsNotAnUndoStep()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.quotedBody = QStringLiteral("> the original");
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ QVERIFY(!body->toPlainText().isEmpty());
+
+ // The seeded quote is not an edit the user made. One Ctrl+Z on a fresh
+ // composer must not wipe it, which reads as the buffer losing its content.
+ //
+ // Worth knowing before judging this test dead weight: removing
+ // clearUndoRedoStacks() alone leaves it GREEN, because setPlainText()
+ // already leaves undo unavailable. The line it guards becomes load-bearing
+ // the moment seedBody() stops using setPlainText, which is a change with
+ // reasons to happen: applyEdit() switched to a QTextCursor replacement for
+ // exactly the undo-stack property this asserts, and a later revision
+ // seeding the quote the same way would put it on the stack. The combined
+ // mutation (seed through a cursor AND drop the clear) does kill this.
+ QVERIFY2(!body->document()->isUndoAvailable(),
+ "the seeded quote is on the undo stack");
+}
+
+void TestMainWindow::aReplySeedsTheHtmlToggleFromTheOriginal()
+{
+ ComposeFixture fixture;
+ // Config says yes; the original says no. The original wins for a reply:
+ // an HTML part in it is a fact about the sender's software, not a guess
+ // about their taste.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=true")));
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.seedHtml = false;
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *toggle = window.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY2(toggle, "no send-html toggle");
+ QVERIFY2(!toggle->isChecked(),
+ "a reply seeded from config rather than from the original");
+
+ // And the other way round, so the test cannot pass by always reading
+ // false: a plain-text config with an HTML original still offers HTML.
+ ComposeFixture plain;
+ QVERIFY(plain.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=false")));
+ ComposeContext htmlReply = newContext();
+ htmlReply.kind = ComposeContext::Kind::ReplyAll;
+ htmlReply.seedHtml = true;
+
+ ComposeWindow second(htmlReply, plain.config(), plain.mailRoot());
+ auto *secondToggle =
+ second.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(secondToggle);
+ QVERIFY2(secondToggle->isChecked(),
+ "a reply-all ignored an HTML original");
+}
+
+void TestMainWindow::aNewMessageSeedsTheHtmlToggleFromConfig()
+{
+ ComposeFixture off;
+ QVERIFY(off.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=false")));
+
+ // seedHtml is deliberately TRUE here and must be ignored: a New message
+ // has no original to take evidence from, so a composer reading it would be
+ // reading a field nothing filled in.
+ ComposeContext context = newContext();
+ context.seedHtml = true;
+
+ ComposeWindow window(context, off.config(), off.mailRoot());
+ auto *toggle = window.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(toggle);
+ QVERIFY2(!toggle->isChecked(), "a New message ignored [compose] send_html");
+
+ ComposeFixture on;
+ QVERIFY(on.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_html=true")));
+ ComposeContext forward = newContext();
+ forward.kind = ComposeContext::Kind::Forward;
+ forward.seedHtml = false;
+
+ ComposeWindow second(forward, on.config(), on.mailRoot());
+ auto *secondToggle =
+ second.findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(secondToggle);
+ QVERIFY2(secondToggle->isChecked(),
+ "a Forward seeded from the original rather than from config");
+}
+
+void TestMainWindow::disablingInputsCoversEveryFieldAndTheToolbar()
+{
+ ComposeFixture fixture;
+ // Zero delay: the countdown is skipped and the send commits at once, which
+ // is the state the inputs must already be disabled in.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ // Heap-allocated and tracked with a QPointer, because ComposeWindow sets
+ // WA_DeleteOnClose and this case really does complete a send: the window
+ // deletes itself on the way out, so a stack instance would be destroyed
+ // twice. Every other case here stays on the stack, since none of them
+ // closes.
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Text."));
+
+ auto *toolbar = window->findChild<QToolBar *>(QStringLiteral("formatToolbar"));
+ auto *to = window->findChild<QLineEdit *>(QStringLiteral("to"));
+ auto *subject = window->findChild<QLineEdit *>(QStringLiteral("subject"));
+ auto *from = window->findChild<QComboBox *>(QStringLiteral("from"));
+ auto *toggle = window->findChild<QCheckBox *>(QStringLiteral("sendHtml"));
+ QVERIFY(toolbar && to && subject && from && toggle);
+
+ QVERIFY(to->isEnabled());
+ QVERIFY(!body->isReadOnly());
+
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY2(sendAction, "no send action");
+ sendAction->trigger();
+
+ // The message must not change between pressing Send and the bytes being
+ // built, so every input goes down for the WHOLE operation, countdown
+ // included. The body is made read-only rather than disabled, so its text
+ // stays selectable and legible while the send runs.
+ QVERIFY2(!to->isEnabled(), "the To field is still editable during a send");
+ QVERIFY2(!subject->isEnabled(), "the subject is still editable");
+ QVERIFY2(!from->isEnabled(), "the account can still be changed");
+ QVERIFY2(!toggle->isEnabled(), "the HTML toggle can still be flipped");
+ QVERIFY2(body->isReadOnly(), "the body is still writable during a send");
+ QVERIFY2(!toolbar->isEnabled(), "the formatting toolbar is still live");
+ auto *attachments =
+ window->findChild<QListWidget *>(QStringLiteral("attachments"));
+ QVERIFY(attachments);
+ QVERIFY2(!attachments->isEnabled(),
+ "the attachment list is still live during a send");
+
+ // /bin/true is the fixture's send command, so the send succeeds and the
+ // composer closes itself: the message went, and holding a composer open
+ // for a message already sent invites sending it twice. Waited on rather
+ // than asserted immediately, since the process is handed to the event loop
+ // and nothing here blocks on it. WA_DeleteOnClose then destroys the
+ // window, which is what the QPointer observes.
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // And the sent copy really was filed, which is the stage after the send
+ // and the one whose failure the design treats as the worst outcome here.
+ const QString sentCur =
+ fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+}
+
+void TestMainWindow::aFailedSendCanBeRetriedWithoutFilingTheWrongCopy()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ // A stub whose outcome is switched by a sentinel file, so ONE configured
+ // command can fail and then succeed. It appends its stdin to a log, which
+ // is what makes the delivery count observable: the defect this guards
+ // against files a sent copy of the FIRST message when the second finishes,
+ // and a receiver count is the only thing that shows it.
+ QTemporaryDir stubDir;
+ QVERIFY(stubDir.isValid());
+ const QString sentinel = stubDir.filePath(QStringLiteral("succeed"));
+ const QString stub = stubDir.filePath(QStringLiteral("send.sh"));
+ {
+ QFile script(stub);
+ QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&script);
+ out << "#!/bin/sh\n"
+ << "cat >> " << stubDir.filePath(QStringLiteral("stdin.log")) << "\n"
+ << "[ -f " << sentinel << " ] || { echo 'refused' >&2; exit 1; }\n"
+ << "exit 0\n";
+ }
+ QVERIFY(QFile::setPermissions(
+ stub, QFileDevice::ReadOwner | QFileDevice::WriteOwner
+ | QFileDevice::ExeOwner));
+
+ // A FRESH Config, not a copy of the fixture's reloaded: Config::load()
+ // does not clear what a previous load put there, so a copy keeps the
+ // fixture's /bin/true and this test would silently exercise a command that
+ // always succeeds. Measured, and it produced a green nothing.
+ Config config;
+ {
+ const QString path = QStringLiteral("%1/retry.conf").arg(stubDir.path());
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.acct]\n"
+ << "name=Test User\n"
+ << "address=user@example.org\n"
+ << "maildir=acct\n"
+ << "trash=Trash\n"
+ << "drafts=Drafts\n"
+ << "sent=Sent\n"
+ << "send_command=" << stub << "\n"
+ << "\n[compose]\n"
+ << "send_delay_ms=0\n";
+ file.close();
+ config.load(path);
+ }
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, config, fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ body->setPlainText(QStringLiteral("FIRST attempt."));
+ sendAction->trigger();
+
+ // The failure re-enables the composer intact and shows the stderr; the
+ // window stays open and the draft stays.
+ auto *pane = window->findChild<QWidget *>(QStringLiteral("sendLogPane"));
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->isHidden(), 15000);
+
+ QVERIFY2(!window.isNull(), "a failed send closed the composer");
+ QVERIFY2(body->isEnabled() && !body->isReadOnly(),
+ "a failed send left the composer disabled");
+
+ // Correct the message and send again, this time succeeding. Without
+ // Qt::SingleShotConnection on the per-send connect, the first send's
+ // lambda is still attached: the second result runs BOTH, and 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.
+ QFile marker(sentinel);
+ QVERIFY(marker.open(QIODevice::WriteOnly));
+ marker.close();
+
+ body->setPlainText(QStringLiteral("SECOND attempt."));
+ sendAction->trigger();
+
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // Exactly ONE sent copy, and it is the second message. Two files, or one
+ // carrying the first attempt, is the accumulated-receiver defect.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ const QStringList filed =
+ QDir(sentCur, {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(filed.size(), 1);
+
+ QFile copy(sentCur + QLatin1Char('/') + filed.first());
+ QVERIFY(copy.open(QIODevice::ReadOnly));
+ const QByteArray bytes = copy.readAll();
+ QVERIFY2(bytes.contains("SECOND attempt."),
+ "the filed copy is not the message that was sent");
+ QVERIFY2(!bytes.contains("FIRST attempt."),
+ "the filed copy is the FIRST message, which never went");
+}
+
+void TestMainWindow::anUnchangedMessageIsNotWrittenAgain()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("Once."));
+ QVERIFY(window.saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ const QStringList first =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(first.size(), 1);
+
+ // Nothing has changed, so nothing is written. Every autosave produces a
+ // Maildir write that mbsync uploads, so this check and the debounce
+ // together are what keep a message to a few revisions rather than dozens.
+ //
+ // The FILENAME is what shows it: DraftStore always generates a fresh name
+ // and unlinks the previous one, so a redundant write leaves exactly one
+ // file too, and a count alone cannot tell a skipped write from a repeated
+ // one. Two runs of this test asserting only on the count would pass
+ // against no check at all.
+ QVERIFY2(window.saveDraftNow(), "the redundant save reported failure");
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList second =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(second, first);
+
+ // And a real change still writes: a check that skipped everything would
+ // pass the assertion above and lose the user's text.
+ body->setPlainText(QStringLiteral("Twice."));
+ QVERIFY(window.saveDraftNow());
+ const QStringList third =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(third.size(), 1);
+ QVERIFY2(third != first, "a changed message was not written");
+}
+
+void TestMainWindow::closingInsideTheDebounceStillSavesTheDraft()
+{
+ ComposeFixture fixture;
+ // A debounce far longer than this test, so the timer provably never fires
+ // and the only thing that can write is the close itself.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("autosave_interval_ms=600000")));
+
+ // Heap-allocated: WA_DeleteOnClose destroys the window on the way out, so
+ // a stack instance would be destroyed twice.
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(newContext(), fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ body->setPlainText(QStringLiteral("A paragraph typed and not yet saved."));
+ QVERIFY(window->hasUnsavedEdits());
+
+ // The timer has NOT fired. Asserted rather than assumed: if it had, the
+ // draft below would prove nothing about the close path.
+ auto *timer = window->findChild<QTimer *>(QStringLiteral("autosave"));
+ QVERIFY(timer);
+ QVERIFY2(timer->isActive(), "the debounce is not running");
+ QCOMPARE(fixture.draftCount(), 0);
+
+ // The window manager's X button, which is the route that reaches
+ // closeEvent. Typing a paragraph and pressing it inside the debounce
+ // interval must not lose the text.
+ window->close();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 5000);
+
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ QVERIFY2(written.readAll().contains("A paragraph typed and not yet saved."),
+ "the close wrote a draft that is not the text that was typed");
+}
+
+void TestMainWindow::closingAfterASendWritesNoFurtherDraft()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ body->setPlainText(QStringLiteral("Text that is about to be sent."));
+
+ // A draft on disk first, so the send's removal of it is observable and the
+ // close-path save has something it could wrongly put back.
+ QVERIFY(window->saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ // Now edit again WITHOUT saving, so m_dirty is true at the moment the
+ // send completes. This is what makes the m_finished guard load-bearing:
+ // without it the close that follows a successful send would write a draft
+ // for a message already sent, restoring the file the send just unlinked.
+ body->setPlainText(QStringLiteral("Text that is about to be sent, edited."));
+ QVERIFY(window->hasUnsavedEdits());
+
+ sendAction->trigger();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // The message went, so the drafts folder is EMPTY. A draft left behind is
+ // a message the user sees waiting to be finished when it has already been
+ // delivered.
+ QCOMPARE(fixture.draftCount(), 0);
+
+ // And the sent copy is there, so this is a completed send rather than a
+ // send that never happened leaving nothing behind either way.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+}
+
+void TestMainWindow::aCloseDuringTheCountdownIsRefused()
+{
+ ComposeFixture fixture;
+ // A countdown long enough to close inside. The default is 5000; this is
+ // the window the guard exists for and it must be provably still open when
+ // the close is attempted.
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=30000")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+ body->setPlainText(QStringLiteral("Sent after a countdown."));
+
+ sendAction->trigger();
+
+ // Still counting down: the popup is up and nothing has been sent. The
+ // sent folder is the evidence, since it is written only after the command
+ // succeeds.
+ auto *dialog = window->findChild<SendDialog *>();
+ QVERIFY2(dialog, "no send popup");
+ QVERIFY2(!dialog->isCommitted(), "the countdown already committed");
+
+ // Close during the countdown. Refused: accepting it would destroy this
+ // window, take the parented SendDialog down with it, and committed() would
+ // never fire. The user pressed Send, watched a countdown, and would
+ // believe the mail went.
+ window->close();
+
+ // Given a moment for a deletion event to be delivered if one was posted,
+ // then asserted still alive. An immediate check would pass against a
+ // deleteLater() already queued.
+ QTest::qWait(300);
+ QVERIFY2(!window.isNull(),
+ "the close was accepted during the countdown, so the send was "
+ "silently abandoned after the user pressed Send");
+ QVERIFY2(window->isVisible() || !window.isNull(), "the window went away");
+
+ // The send never happened, which is the point: nothing was filed.
+ const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 0u);
+
+ // Cleaned up by hand, since the window refuses to close while the popup is
+ // up and the test must not leak it into the next case.
+ delete window;
+}
+
+void TestMainWindow::aFailedSendKeepsTheTextThatFailedToGo()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ QTemporaryDir stubDir;
+ QVERIFY(stubDir.isValid());
+ const QString stub = stubDir.filePath(QStringLiteral("fail.sh"));
+ {
+ QFile script(stub);
+ QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&script);
+ out << "#!/bin/sh\ncat > /dev/null\necho 'refused' >&2\nexit 1\n";
+ }
+ QVERIFY(QFile::setPermissions(
+ stub, QFileDevice::ReadOwner | QFileDevice::WriteOwner
+ | QFileDevice::ExeOwner));
+
+ Config config;
+ {
+ const QString path = stubDir.filePath(QStringLiteral("fail.conf"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ QTextStream out(&file);
+ out << "[account.acct]\n"
+ << "name=Test User\naddress=user@example.org\n"
+ << "maildir=acct\ntrash=Trash\ndrafts=Drafts\nsent=Sent\n"
+ << "send_command=" << stub << "\n"
+ << "\n[compose]\nsend_delay_ms=0\n";
+ file.close();
+ config.load(path);
+ }
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, config, fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ // An OLD revision on disk, then an edit that is not saved. send() builds
+ // from the widgets without saving, so without the fix the file left behind
+ // after the failure is the old text: the user watches their correction be
+ // sent, sees it fail, and gets the uncorrected version back.
+ body->setPlainText(QStringLiteral("The ORIGINAL text."));
+ QVERIFY(window->saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ body->setPlainText(QStringLiteral("The CORRECTED text."));
+ sendAction->trigger();
+
+ auto *pane = window->findChild<QWidget *>(QStringLiteral("sendLogPane"));
+ QVERIFY(pane);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->isHidden(), 15000);
+ QVERIFY2(!window.isNull(), "a failed send closed the composer");
+
+ // Exactly one draft, and it is the text that was attempted.
+ QCOMPARE(fixture.draftCount(), 1);
+ const QStringList files =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(files.size(), 1);
+ QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first());
+ QVERIFY(written.open(QIODevice::ReadOnly));
+ const QByteArray bytes = written.readAll();
+ QVERIFY2(bytes.contains("The CORRECTED text."),
+ "the draft kept after a failed send is not what was attempted");
+ QVERIFY2(!bytes.contains("The ORIGINAL text."),
+ "the draft kept after a failed send is the PRE-EDIT revision");
+
+ delete window;
+}
+
+void TestMainWindow::aSmallSizeLimitIsNotDescribedAsZeroMegabytes()
+{
+ // Integer MB division made every figure under a megabyte read as "0 MB",
+ // in BOTH halves of the same sentence: "'x' is 0 MB. Many mail servers
+ // refuse messages above about 0 MB."
+ QVERIFY2(!ComposeWindow::humanSize(500 * 1024).contains(QStringLiteral("0 MB")),
+ "half a megabyte is described as 0 MB");
+ QVERIFY2(!ComposeWindow::humanSize(1000).contains(QStringLiteral("0 MB")),
+ "a kilobyte is described as 0 MB");
+
+ // The unit steps down rather than reporting zero of a larger one.
+ QVERIFY(ComposeWindow::humanSize(500 * 1024).contains(QStringLiteral("KB")));
+ QVERIFY(ComposeWindow::humanSize(512).contains(QStringLiteral("bytes")));
+
+ // A decimal while the figure is small enough for it to say something, so
+ // 26 MB and 26.2 MB are not the same string.
+ QVERIFY(ComposeWindow::humanSize(26214400).contains(QStringLiteral("MB")));
+ QVERIFY2(ComposeWindow::humanSize(1024 * 1024 * 3 / 2)
+ .contains(QStringLiteral(".")),
+ "1.5 MB lost its decimal");
+}
+
#include "test_mainwindow.moc"