aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-06 15:26:31 +0200
committerDanilo M. <danix@danix.xyz>2026-09-06 15:26:31 +0200
commit51b8fd5708d23108d532be1cf38e4bb619eeb777 (patch)
tree1f98bee2bf6da1950ae8958eaff737bfbf8d317b /src
parentc896eaf84b8b784622fd21380cf8d7f6d51028b2 (diff)
downloadqtmaildir-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 'src')
-rw-r--r--src/composecontext.cpp5
-rw-r--r--src/composewindow.cpp29
-rw-r--r--src/composewindow.h11
-rw-r--r--src/messagebuilder.cpp17
-rw-r--r--src/types.h27
5 files changed, 82 insertions, 7 deletions
diff --git a/src/composecontext.cpp b/src/composecontext.cpp
index b02b790..e594e24 100644
--- a/src/composecontext.cpp
+++ b/src/composecontext.cpp
@@ -572,6 +572,11 @@ ComposeContext ComposeContextBuilder::forDraft(const Config &config,
// arriving one step later.
context.draftPath = resolved;
+ // Item 165. The identity this draft already has, so the next autosave
+ // REPLACES the message on the server rather than minting a second one.
+ // The parser already reads this header; nothing used it until now.
+ context.draftMessageId = draft.messageId;
+
const auto addresses = [](const QString &header) {
QStringList out;
for (const Recipient &recipient : parseAddressHeader(header))
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index 7952efa..218edef 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -902,6 +902,9 @@ void ComposeWindow::seedFields()
// The draft file this composer is resuming, so the next autosave REPLACES
// it rather than writing a second one beside it.
m_draftPath = m_context.draftPath;
+ // And the identity that file already carries (item 165), so resuming does
+ // not start a second one.
+ m_draftMessageId = m_context.draftMessageId;
// New and Forward seed from [compose] send_html; Reply and Reply-all seed
// from whether the original carried a text/html part, ignoring the config
@@ -1365,7 +1368,10 @@ bool ComposeWindow::saveDraftNow()
return true;
}
- const OutgoingMessage message = currentMessage();
+ // Not const: the draft's own Message-ID is assigned below (item 165).
+ // fingerprintOf() does not read that field, so the dirty check above is
+ // unaffected by it.
+ OutgoingMessage message = currentMessage();
// The dirty CHECK, not just the flag: an unchanged message means no file
// is written and no sync is provoked. Every autosave produces a Maildir
@@ -1374,10 +1380,13 @@ bool ComposeWindow::saveDraftNow()
//
// Checked BEFORE the build, and on the message rather than on the bytes.
// The plan's draft compared built.bytes, which can never match: GMime is
- // given a fresh Date and Message-ID on every build, so two builds of an
- // unchanged message differ. That check would have read as working while
- // writing a file on every debounce. Doing it first also skips the
- // blocking build entirely for the no-change case, which is the common one.
+ // given a fresh Date on every build, so two builds of an unchanged message
+ // differ. That check would have read as working while writing a file on
+ // every debounce. Doing it first also skips the blocking build entirely
+ // for the no-change case, which is the common one.
+ //
+ // The Message-ID is no longer part of that difference since item 165, but
+ // the Date still is, so comparing bytes remains wrong.
const QString fingerprint = fingerprintOf(message);
if (!m_savedFingerprint.isEmpty() && fingerprint == m_savedFingerprint) {
setDirty(false);
@@ -1391,6 +1400,12 @@ bool ComposeWindow::saveDraftNow()
// crosses the worker boundary, and a second threading model for one call
// is worse than the stall. If someone is measuring a composer freeze, this
// line is where to look.
+ // Item 165. Every revision of this draft builds under ONE id, so a save
+ // replaces the message on the server rather than adding one. Empty on the
+ // first save of a new draft, which mints one below; seeded from the file
+ // for a resumed draft. The SEND path deliberately does not do this.
+ message.messageId = m_draftMessageId;
+
const MessageBuilder::Result built = MessageBuilder::build(message, account);
if (!built.ok()) {
m_saveFailed = true;
@@ -1426,6 +1441,10 @@ bool ComposeWindow::saveDraftNow()
}
m_draftPath = written.path;
+ // Kept for the NEXT revision (item 165). Assigned from the build rather
+ // than from m_draftMessageId so the first save adopts the id GMime just
+ // generated, which is what makes every later save a replacement.
+ m_draftMessageId = built.messageId;
m_savedFingerprint = fingerprint;
setDirty(false);
m_saveFailed = false;
diff --git a/src/composewindow.h b/src/composewindow.h
index 9affce1..87b105a 100644
--- a/src/composewindow.h
+++ b/src/composewindow.h
@@ -371,6 +371,17 @@ private:
QString m_draftPath; ///< The revision on disk, unlinked on the next write.
+ /// The Message-ID this draft's revisions share, empty until the first
+ /// save.
+ ///
+ /// Item 165. Fed back into the next build so a save REPLACES the message
+ /// mbsync uploaded rather than adding one; without it the server kept one
+ /// message per revision, measured as four for a single reply. Seeded from
+ /// the file when a draft is resumed, so reopening does not start a second
+ /// identity. Deliberately NOT used on the send path: the sent copy is a
+ /// different item and mints its own id.
+ QString m_draftMessageId;
+
/// A fingerprint of the message the last successful save wrote, for the
/// dirty CHECK.
///
diff --git a/src/messagebuilder.cpp b/src/messagebuilder.cpp
index fe23862..ea428ee 100644
--- a/src/messagebuilder.cpp
+++ b/src/messagebuilder.cpp
@@ -308,8 +308,21 @@ Result build(const OutgoingMessage &message, const Account &account)
// invariant the next early return forgets; it is assigned once, beside the
// bytes, on the one path that succeeds.
QString messageId;
- char *generatedId = g_mime_utils_generate_message_id(domainUtf8.constData());
- if (generatedId) {
+ if (!message.messageId.isEmpty()) {
+ // Item 165. A draft revision reuses the id its previous build
+ // returned, so a save REPLACES the message on the server rather than
+ // adding one. Only the autosave path supplies this; a send leaves it
+ // empty and gets a fresh id below, because two sent messages must
+ // never share one.
+ //
+ // set_message_id takes the id WITHOUT angle brackets and adds them,
+ // which is the form generate_message_id returns and the form this is
+ // stored in.
+ const QByteArray supplied = message.messageId.toUtf8();
+ g_mime_message_set_message_id(mime, supplied.constData());
+ messageId = message.messageId;
+ } else if (char *generatedId =
+ g_mime_utils_generate_message_id(domainUtf8.constData())) {
g_mime_message_set_message_id(mime, generatedId);
messageId = QString::fromUtf8(generatedId);
g_free(generatedId);
diff --git a/src/types.h b/src/types.h
index ee2627f..fb7901c 100644
--- a/src/types.h
+++ b/src/types.h
@@ -352,6 +352,15 @@ struct ComposeContext
/// to make room. It is the message itself.
QString body;
+ /// The Message-ID the resumed draft already has, empty for every other
+ /// kind.
+ ///
+ /// Item 165. Seeded into ComposeWindow::m_draftMessageId so a reopened
+ /// draft keeps the identity it was saved under instead of starting a
+ /// second one on its next autosave. Distinct from `inReplyTo`, which is
+ /// the ORIGINAL's id when the draft is a reply; this is the draft's own.
+ QString draftMessageId;
+
/// The draft file this composer OWNS, empty for every other kind.
///
/// Seeded into ComposeWindow::m_draftPath so the next autosave REPLACES
@@ -379,6 +388,24 @@ struct OutgoingMessage
QString inReplyTo;
QStringList references;
+ /// The Message-ID to build under, or empty to mint a fresh one.
+ ///
+ /// Item 165. A draft keeps ONE identity across its revisions, so an
+ /// autosave replaces the message it wrote last time instead of adding
+ /// another. Every save used to generate a new id, and mbsync uploads each
+ /// revision to the drafts folder before the next save removes the local
+ /// file, so the server ended up holding one message per revision:
+ /// measured as four for a single reply on the user's own mail. Deleting a
+ /// local file does not retract an uploaded one, which is why the local
+ /// cleanup, which is correct, could never fix this.
+ ///
+ /// **Empty on the SEND path, deliberately.** The sent copy is a different
+ /// item from the draft, at the user's own decision (item 165), and two
+ /// sent messages sharing an id would be far worse than two ids for one
+ /// draft. So this is opt-in: only ComposeWindow's autosave fills it, from
+ /// the id the previous build returned.
+ QString messageId;
+
/// Item 171. The forwarded original's HTML, already sanitised, appended to
/// the HTML alternative below the user's own text.
///