diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-06 15:26:31 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-06 15:26:31 +0200 |
| commit | 51b8fd5708d23108d532be1cf38e4bb619eeb777 (patch) | |
| tree | 1f98bee2bf6da1950ae8958eaff737bfbf8d317b /tests/test_mainwindow.cpp | |
| parent | c896eaf84b8b784622fd21380cf8d7f6d51028b2 (diff) | |
| download | qtmaildir-51b8fd5708d23108d532be1cf38e4bb619eeb777.tar.gz qtmaildir-51b8fd5708d23108d532be1cf38e4bb619eeb777.zip | |
fix: keep one Message-ID across a draft's revisions
Every autosave called MessageBuilder::build(), which generated a fresh
Message-ID unconditionally, so each revision of a draft was a different
message rather than a new version of one. The entry called this invisible
while the file is replaced correctly, and that turned out to be wrong:
mbsync uploads each revision to the drafts folder before the next save
removes the local file, so the server keeps one message per revision and
syncs them all back down. Measured on real mail as four independent
messages for a single reply, all four carrying a ,U= infix, threading
into the conversation and putting a draft tag on a Sent row. Deleting a
local file does not retract an uploaded one, which is why the local
cleanup, which is correct, could never fix it.
The user chose a stable id while drafting, discarded at send: the sent
copy is a different item from the draft, and the draft is deleted once
the message goes out, which the code already did.
Three links, none of which existed. OutgoingMessage::messageId is the
field, where empty means generate, so the send path is unchanged by
construction rather than by remembering to clear it.
MessageBuilder::build() uses a supplied id when there is one.
ComposeWindow::m_draftMessageId holds the identity between revisions,
assigned from built.messageId so the first save adopts the id GMime just
generated, and ComposeContext::draftMessageId carries it across a reopen,
read in forDraft() from ParsedMessage::messageId, which the parser
already provided and nothing had ever used.
Five tests, because the property spans three objects and a test at any
one of them passes while another link is broken. Two are the safety
constraint rather than the feature: a field defaulting to a fixed value
would satisfy the reuse test and make two sent messages share an id,
which is far worse than the defect this fixes.
One comment is corrected rather than left: the autosave's dirty check
justified comparing the message rather than the built bytes with "GMime
is given a fresh Date and Message-ID on every build". Half of that is no
longer true. The Date still is, so the conclusion stands.
Revisions already on the server are not touched by this; the four found
on real mail were deleted by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
Diffstat (limited to 'tests/test_mainwindow.cpp')
| -rw-r--r-- | tests/test_mainwindow.cpp | 48 |
1 files changed, 48 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 5817ac4..f20701e 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -518,6 +518,7 @@ private slots: void doubleClickingADraftOpensTheComposer(); void aResumedDraftReplacesItsFileRatherThanAddingOne(); void aResumedDraftKeepsItsBlindRecipients(); + void aResumedDraftKeepsTheMessageIdItWasSavedUnder(); void aDraftRenamedByASyncStillReopensAndReplacesItsFile(); void theComposerSplitsItsToolbarByScope(); void ccAndBccHideBehindADisclosure(); @@ -14313,6 +14314,53 @@ void TestMainWindow::aDraftRenamedByASyncStillReopensAndReplacesItsFile() "into two files and both would reach the server"); } +/// Item 165, the third link in the chain and the one a composer-only test +/// cannot reach: reopening a draft must keep the identity the FILE already +/// has, or the next autosave starts a second one and the server ends up with +/// two messages for one draft after all. +void TestMainWindow::aResumedDraftKeepsTheMessageIdItWasSavedUnder() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + OutgoingMessage message; + message.accountKey = QStringLiteral("acct"); + message.to = { QStringLiteral("someone@example.org") }; + message.subject = QStringLiteral("Resumed"); + message.markdownBody = QStringLiteral("Body."); + message.messageId = QStringLiteral("already-saved-under@example.org"); + + const QString folder = fixture.mailRoot() + QStringLiteral("/acct/Drafts"); + const QString path = writeDraftFile(folder, message, + fixture.config().account( + QStringLiteral("acct"))); + QVERIFY(!path.isEmpty()); + + const ComposeContext context = + ComposeContextBuilder::forDraft(fixture.config(), path); + QCOMPARE(context.draftMessageId, + QStringLiteral("already-saved-under@example.org")); + + // And it reaches the next revision, which is the property that matters: + // the context carrying it is only half the chain. + ComposeWindow window(context, fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + body->setPlainText(QStringLiteral("Edited after reopening.")); + + QSignalSpy saved(&window, &ComposeWindow::draftSaved); + auto *save = window.findChild<QAction *>(QStringLiteral("compose_save")); + QVERIFY(save); + save->trigger(); + QCOMPARE(saved.size(), 1); + + QFile written(saved.first().first().toString()); + QVERIFY(written.open(QIODevice::ReadOnly)); + const QString text = QString::fromUtf8(written.readAll()); + QVERIFY2(text.contains(QStringLiteral("<already-saved-under@example.org>")), + "the revision written after a reopen carries a different id"); +} + void TestMainWindow::aResumedDraftKeepsItsBlindRecipients() { // MessageBuilder writes Bcc into the draft file deliberately, and says |
