From 58f13ad9d78a07aab1d683462834a2493078744d Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 24 Aug 2026 12:41:46 +0200 Subject: feat(compose): open a draft to finish it Item 153. DraftStore had a write() and no reader, and nothing opened a composer from an existing message, so a draft rendered like ordinary mail and could never be finished or sent. ComposeContextBuilder::forDraft() reads one back. A new Kind::Draft seeds every field verbatim: the subject takes no Re:/Fwd: prefix, and the body goes in exactly as it was left, with none of seedBody()'s quote framing. It is reachable by double-click and by an edit_draft action in the Message menu. Three things the shape of this depends on. A resumed draft must OWN its file. Maildir has no in-place edit, so an autosave writes a new file and unlinks the old one; a composer that did not know its own path would leave the original behind and one message would become two. ComposeContext::draftPath carries it into m_draftPath, which the autosave already knew how to replace. MimeParser had no bcc, and nothing had ever needed one. MessageBuilder writes Bcc into the draft file deliberately and explains why, so a resumed draft that ignored it would drop every blind recipient from the message the user then finishes and sends, reporting nothing. edit_draft is gated on the file being inside a configured drafts folder, matched on the PATH. A `draft` tag is not enough: notmuch surfaces the Maildir D flag as one, and a message flagged by another client sits in the inbox. Offered on ordinary mail, the composer would own a file it did not write and the first autosave would delete a received message. And a live defect found on the way, which is most of why this took as long as it did. updateComposeActions() ran only from onSelectionChanged. Both signals fire for an ordinary click, so nothing had noticed; but running a query and setting the current index emits currentRowChanged ALONE, so the enablement was computed against the previously selected row. Edit draft stayed disabled on a draft selected that way, and the reply family had the same blind spot with no test that could see it. Now connected to both. Reading currentRowChanged is safe here for the reason CLAUDE.md gives: it answers "which row is current", and no count is read. WorkerBackedWindow::AccountSpec gains a drafts field, which the two new tests need and which no fixture could express before. --- src/composewindow.cpp | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) (limited to 'src/composewindow.cpp') diff --git a/src/composewindow.cpp b/src/composewindow.cpp index 5a92fa2..a64736f 100644 --- a/src/composewindow.cpp +++ b/src/composewindow.cpp @@ -587,16 +587,27 @@ void ComposeWindow::seedFields() m_to->setText(m_context.to.join(QStringLiteral(", "))); m_cc->setText(m_context.cc.join(QStringLiteral(", "))); + // Only a resumed draft carries one, and dropping it would remove every + // blind recipient from the message the user then finishes and sends. + m_bcc->setText(m_context.bcc.join(QStringLiteral(", "))); m_subject->setText(m_context.subject); + // 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; + // 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 // value. An HTML part in the original is a fact about the sender's // software, not a guess about their taste. - const bool isReply = m_context.kind == ComposeContext::Kind::Reply - || m_context.kind == ComposeContext::Kind::ReplyAll; - m_sendHtml->setChecked(isReply ? m_context.seedHtml - : m_config.compose().sendHtml); + // A DRAFT seeds from itself for the same reason a reply seeds from the + // original: the user already made this choice, and the config default is a + // guess that would silently overrule it. + const bool fromContext = m_context.kind == ComposeContext::Kind::Reply + || m_context.kind == ComposeContext::Kind::ReplyAll + || m_context.kind == ComposeContext::Kind::Draft; + m_sendHtml->setChecked(fromContext ? m_context.seedHtml + : m_config.compose().sendHtml); } void ComposeWindow::revealCcBccIfUsed() @@ -613,6 +624,16 @@ void ComposeWindow::revealCcBccIfUsed() void ComposeWindow::seedBody() { + // A resumed draft is the message ITSELF, so it goes in exactly as it was + // left: no attribution, no quote markers, no blank lines added and no + // cursor moved to make room for a reply that is already written. + if (m_context.kind == ComposeContext::Kind::Draft) { + m_body->setPlainText(m_context.body); + m_body->moveCursor(QTextCursor::End); + m_body->document()->clearUndoRedoStacks(); + return; + } + if (m_context.quotedBody.isEmpty()) return; -- cgit v1.2.3