aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-24 12:41:46 +0200
committerDanilo M. <danix@danix.xyz>2026-08-24 12:41:46 +0200
commit58f13ad9d78a07aab1d683462834a2493078744d (patch)
treebd2273b161b214b986c11a8d00d3e20138c9f47b
parentb0d612c8ea232674ac0734b1121cfd0bb50d532b (diff)
downloadqtmaildir-58f13ad9d78a07aab1d683462834a2493078744d.tar.gz
qtmaildir-58f13ad9d78a07aab1d683462834a2493078744d.zip
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.
-rw-r--r--CHANGELOG.md5
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md44
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md2
-rw-r--r--src/composecontext.cpp63
-rw-r--r--src/composecontext.h13
-rw-r--r--src/composewindow.cpp29
-rw-r--r--src/keymap.cpp1
-rw-r--r--src/mainwindow.cpp123
-rw-r--r--src/mainwindow.h13
-rw-r--r--src/mimeparser.cpp1
-rw-r--r--src/mimeparser.h7
-rw-r--r--src/types.h17
-rw-r--r--tests/test_mainwindow.cpp289
-rw-r--r--translations/qtmaildir_it_IT.ts16
14 files changed, 617 insertions, 6 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19e52d8..1ee21a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,11 @@ point at which they are stable.
reply follows what the message being answered used.
- Drafts autosave to the account's `drafts` folder as ordinary Maildir files,
so mbsync carries them to the server and another client can pick one up.
+- **Drafts can be opened and finished.** Double-click one, or use Edit draft
+ in the Message menu. The composer takes ownership of the file, so saving
+ replaces the draft rather than leaving a second copy, and the Bcc list the
+ draft carries is read back rather than dropped. The action is offered only
+ on a message that really is in a drafts folder.
- **A Drafts filter** in the query row, beside Sent and Trash. It matches each
account's `drafts` folder, so it finds what the composer actually writes
rather than trusting a flag. An account that configures no drafts folder
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
index 133a761..0f7181b 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
@@ -7348,3 +7348,47 @@ what a drafts folder is; they are separate readers and neither should start
importing the other.
---
+
+## 153. A draft cannot be opened for editing, so it is write-only
+
+**Observed (user, 2026-08-24).** Found the moment item 138 gave drafts a
+button: "Double clicking on a draft should open the message in the editor
+window", and "there's no edit action anywhere, a draft is useless as is".
+
+**Cause (verified in code).** `DraftStore` had a `write()` and no reader, and
+nothing anywhere opened a composer from an existing message. A draft therefore
+rendered in the message pane like ordinary mail and could never be finished.
+
+**Outcome.** `ComposeContextBuilder::forDraft()`, a `Kind::Draft` that seeds
+every field verbatim, `edit_draft` in the Message menu, and double-click.
+
+**Three things worth keeping.**
+
+**A resumed draft must own its file.** Maildir has no in-place edit, so an
+autosave writes a new file and unlinks the old; 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 says why, so a
+resumed draft that ignored it would drop every blind recipient from the message
+the user then finishes and sends, silently. That is the failure this item was
+most likely to ship.
+
+**The gate is not cosmetic.** `edit_draft` is offered only on a file inside a
+configured drafts folder, matched on the PATH: a `draft` tag is not enough,
+since 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 the reason this took as long as
+it did.** `updateComposeActions()` ran only from `onSelectionChanged`. Both
+signals fire for an ordinary click, so nothing had ever noticed; but running a
+query and setting the current index emits `currentRowChanged` ALONE, so the
+enablement was computed against the previously selected row. Measured: Edit
+draft stayed disabled on a draft selected that way, and the reply family had
+the same blind spot without a test that could see it. It is now connected to
+both signals. Reading `currentRowChanged` is safe here for the reason
+`CLAUDE.md` gives: it answers "which row is current", and no count is read.
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 79320bd..e203856 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -222,7 +222,7 @@ taking that too literally.
| 150 | The receive-only ribbon stays up after the message that raised it is gone | defect | S | **done** 2026-08-24, unreleased. One line in `MessageView::clear()`, beside the blocked-content bar, the stale notice and the attachment bar it already reset by hand. Only `setReceiveOnlyAccount()` hid the ribbon, which every SELECTION change reaches, so a row-to-row move was never the reproducer: it survived the FOUR routes that blank the pane without one (`clear_pane`, `clear_selection`, a new query, a multi-row selection). The first test written for it passed against the defect for exactly that reason |
| 151 | The message-pane bars blend into the UI and carry no severity | presentation | S | **done** 2026-08-24, unreleased. Two severities as the user asked: yellow for a warning that only explains (the receive-only ribbon), blue for one offering an action (remote content blocked, stale thread), each with its own light and dark set read off `QPalette::Base` as `HtmlBuilder` does. The blocked row had to become a WIDGET first: it was a bare `QHBoxLayout`, which has nothing to paint a ground on, and its six `hide()` sites then had to move to the wrapper or a painted empty strip would show. Both action bars put the button right of a stretch |
| 152 | Signatures are not managed at all | v2 | ? | open, 2026-08-24, from the notes, and the user added a constraint the same day: a signature is **not tied to an account**, and is switched from a control in the composer's editor bar. That rules out the obvious `[account.*] signature` key as the whole answer. Still unspecified in the rest: where the text is stored, how it interacts with the quote, and whether the HTML part gets its own form |
-| 153 | A draft cannot be opened for editing, so it is write-only | defect | M | open, 2026-08-24, from the notes, found by the user the moment item 138 gave drafts a button. Verified: `DraftStore` has a `write()` and no reader, nothing anywhere calls anything like `loadDraft`, and no action opens a composer from an existing message. A draft therefore renders in the message pane like ordinary mail and can never be finished or sent. `ComposeContextBuilder` already parses a file into recipients and a body for Reply and Forward, so the parsing exists; what is missing is a context KIND that owns the original (a resumed draft must replace its file on save, not accumulate a second one) and a way in. **The two halves of the note are one item:** double-click on a draft, and an Edit action for every other route |
+| 153 | A draft cannot be opened for editing, so it is write-only | defect | M | **done** 2026-08-24, unreleased. `ComposeContextBuilder::forDraft()` reads a draft back into a context; a new `Kind::Draft` seeds the fields verbatim, takes the body with no quote framing, and carries `draftPath` so the autosave REPLACES the file instead of leaving a second copy. `MimeParser` gained `bcc`, which nothing read before: `MessageBuilder` writes Bcc into the draft deliberately, so a resumed draft that ignored it would silently drop every blind recipient. Reachable by double-click and by an `edit_draft` action, gated on the file being in a configured drafts folder because opening ordinary mail this way would make the first autosave DELETE a received message. Found a live defect on the way, see the section |
| 154 | No read confirmation | v2 | ? | open, 2026-08-24, from the notes. `Disposition-Notification-To`, which is a header `MessageBuilder` would add and a request the message pane would have to honour or ignore on the receiving side. Unspecified: whether this is send-side only, and what the reader is asked |
| 155 | No urgency switch on an outgoing message | v2 | S | open, 2026-08-24, from the notes: low, regular, high. `X-Priority` and `Importance`, headers `MessageBuilder` adds; regular writes neither. A control in the composer, and the same question item 144 answered for the HTML toggle applies to where it sits |
| 156 | No delivery confirmation | v2 | ? | open, 2026-08-24, from the notes. Distinct from 154: this is a DSN (`Return-Receipt-To`, or the ESMTP NOTIFY parameter), which is the sending server's to honour rather than the reader's client. Whether it can be requested at all depends on the `send_command`, so this may not be this application's to offer |
diff --git a/src/composecontext.cpp b/src/composecontext.cpp
index 251a028..d0406fc 100644
--- a/src/composecontext.cpp
+++ b/src/composecontext.cpp
@@ -486,6 +486,69 @@ QString ComposeContextBuilder::forwardSubject(const QString &original)
return QStringLiteral("Fwd: ") + original;
}
+ComposeContext ComposeContextBuilder::forDraft(const Config &config,
+ const QString &path)
+{
+ ComposeContext context;
+
+ MimeParser parser;
+ const ParsedMessage draft = parser.parse(path);
+ if (!draft.ok)
+ return context; // Kind::New and empty: the caller reports the failure.
+
+ context.kind = ComposeContext::Kind::Draft;
+ context.originalPath = path;
+ // The file this composer OWNS. Without it the first autosave writes a
+ // second draft and leaves this one behind, so one message becomes two.
+ context.draftPath = path;
+
+ const auto addresses = [](const QString &header) {
+ QStringList out;
+ for (const Recipient &recipient : parseAddressHeader(header))
+ out.append(recipient.rendered);
+ return out;
+ };
+ context.to = addresses(draft.to);
+ context.cc = addresses(draft.cc);
+ // Written into the draft file by MessageBuilder, which explains why. Read
+ // back or every blind recipient is dropped from the finished message,
+ // silently.
+ context.bcc = addresses(draft.bcc);
+
+ // Verbatim, both of them. A draft is the message itself: its subject takes
+ // no Re:/Fwd: prefix and its body is not a quote.
+ context.subject = draft.subject;
+ context.body = draft.plainBody;
+
+ context.seedHtml = draft.hasHtml();
+
+ // The account the draft SAYS it is from, which is the user's own earlier
+ // choice, rather than whichever account owns the folder the file sits in.
+ //
+ // Matched on the bare ADDRESS, never on the rendered form: a display name
+ // may contain an address-looking substring, which is the trap Recipient
+ // exists to keep apart.
+ const QList<Recipient> from = parseAddressHeader(draft.from);
+ if (!from.isEmpty()) {
+ const QString address = from.first().address;
+ for (const Account &account : config.accounts()) {
+ if (account.canSend()
+ && account.address.compare(address, Qt::CaseInsensitive) == 0) {
+ context.accountKey = account.key;
+ break;
+ }
+ }
+ }
+ // The From address names no account that can send any more, which happens
+ // when an account is renamed or its send_command removed between saving
+ // the draft and resuming it. Fall back through the ordinary chain rather
+ // than opening a composer that cannot send.
+ if (context.accountKey.isEmpty())
+ context.accountKey = accountForNew(config, QString());
+
+ return context;
+}
+
QString ComposeContextBuilder::quoteBody(const ParsedMessage &message)
{
QStringList quoted;
diff --git a/src/composecontext.h b/src/composecontext.h
index 4027af0..6f311c7 100644
--- a/src/composecontext.h
+++ b/src/composecontext.h
@@ -165,8 +165,21 @@ QString accountForNew(const Config &config, const QString &selectedAccount);
/// treating it as a prefix means a genuine first reply gets no `Re:` and
/// threads nowhere. See the patterns in composecontext.cpp for the measurement.
QString replySubject(const QString &original);
+
QString forwardSubject(const QString &original);
+/// Builds the context that RESUMES a draft from its file.
+///
+/// Unlike a reply, nothing here is derived: the recipients, the subject and
+/// the body are the draft's own, read back verbatim. The account comes from
+/// the From header rather than from which account owns the file, because a
+/// draft states who it is from and that is the user's own earlier choice.
+///
+/// The returned context carries `draftPath`, which the composer seeds into
+/// the path its autosave replaces. Returns a context whose kind is New, with
+/// nothing filled in, when the file cannot be read.
+ComposeContext forDraft(const Config &config, const QString &path);
+
/// The `>`-prefixed original, with an attribution line.
///
/// Takes a ParsedMessage, NOT a MessageNode: the node carries no body and no
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;
diff --git a/src/keymap.cpp b/src/keymap.cpp
index 0df8450..6cd965a 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -63,6 +63,7 @@ QStringList KeyMap::knownActions()
QStringLiteral("reply_all"),
QStringLiteral("reply_no_quote"),
QStringLiteral("forward"),
+ QStringLiteral("edit_draft"),
QStringLiteral("save_message"),
QStringLiteral("focus_query"),
QStringLiteral("complete_query"),
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index da18869..eb41da9 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -875,6 +875,19 @@ void MainWindow::buildUi()
&QItemSelectionModel::currentRowChanged,
this, &MainWindow::onThreadSelected);
+ // Also on currentRowChanged, and NOT only from onSelectionChanged, which
+ // is where the reply family is answered. Both signals fire for an ordinary
+ // click, but a selection that does not CHANGE emits only this one: running
+ // a query and setting the current index reaches here and never the other,
+ // so the enablement was computed against the previously selected row.
+ // Measured: Edit draft stayed disabled on a draft selected that way.
+ //
+ // Safe on currentRowChanged, which CLAUDE.md restricts to "which row is
+ // current": that is exactly the question here, and no count is read.
+ connect(m_threadView->selectionModel(),
+ &QItemSelectionModel::currentRowChanged, this,
+ [this]() { updateComposeActions(); });
+
// Separate from currentRowChanged: a selection can grow without current
// moving at all. Ctrl+click adds a row and leaves current where it was, and
// selectAll() emits no currentRowChanged whatsoever (verified against
@@ -970,6 +983,28 @@ void MainWindow::composeReply(ComposeContext::Kind kind, bool quote)
requestMessageForCompose(scope.messageIds.first(), kind, quote);
}
+void MainWindow::editDraft()
+{
+ editDraftAt(m_threadView->currentIndex());
+}
+
+void MainWindow::editDraftAt(const QModelIndex &index)
+{
+ // messageScopeFor(), like composeReply(): a thread row means the one
+ // message its card shows.
+ const ActionScope scope = m_model->messageScopeFor({ index });
+ if (scope.messageIds.isEmpty()) {
+ showTransientStatus(tr("No message is selected"));
+ return;
+ }
+
+ // Through the worker for its path, never from the model: the model's path
+ // comes from the query, and a draft is rewritten by every autosave, so a
+ // row that has not been re-queried names a file that no longer exists.
+ requestMessageForCompose(scope.messageIds.first(),
+ ComposeContext::Kind::Draft, false);
+}
+
void MainWindow::requestMessageForCompose(const QString &messageId,
ComposeContext::Kind kind,
bool quote)
@@ -990,6 +1025,24 @@ void MainWindow::requestMessageForCompose(const QString &messageId,
void MainWindow::openComposerFor(const MessageRef &ref,
ComposeContext::Kind kind, bool quote)
{
+ // A draft is RESUMED rather than answered: nothing is derived from it,
+ // and the composer takes ownership of its file. Handled before the reply
+ // machinery below, none of which applies (item 153).
+ if (kind == ComposeContext::Kind::Draft) {
+ const ComposeContext draft =
+ ComposeContextBuilder::forDraft(m_config, ref.filePath);
+ if (draft.kind != ComposeContext::Kind::Draft) {
+ showTransientStatus(tr("That draft could not be read"));
+ return;
+ }
+ if (draft.accountKey.isEmpty()) {
+ showTransientStatus(tr("No account is configured to send"));
+ return;
+ }
+ openComposer(draft);
+ return;
+ }
+
MimeParser parser;
const ParsedMessage original = parser.parse(ref.filePath);
if (!original.ok) {
@@ -1155,6 +1208,50 @@ void MainWindow::markComposersDirtyForTest()
}
}
+bool MainWindow::currentMessageIsADraft() const
+{
+ return indexIsADraft(m_threadView->currentIndex());
+}
+
+bool MainWindow::indexIsADraft(const QModelIndex &current) const
+{
+ if (m_mailRoot.isEmpty())
+ return false;
+
+ if (!current.isValid())
+ return false;
+
+ QString path;
+ if (m_model->isMessageRow(current))
+ path = m_model->messageAt(current).filePath;
+ else
+ path = m_model->threadFor(current).firstMessagePath;
+ if (path.isEmpty())
+ return false;
+
+ // ThreadSummary::firstMessagePath is RELATIVE to the mail root and
+ // MessageNode::filePath is ABSOLUTE, the asymmetry accountForCurrentMessage()
+ // documents. Compared as a resolved absolute path against each account's
+ // drafts folder.
+ const QString absolute = QDir::isAbsolutePath(path)
+ ? path
+ : QDir(m_mailRoot).absoluteFilePath(path);
+
+ for (const Account &account : m_config.accounts()) {
+ if (account.drafts.isEmpty())
+ continue;
+ const QString folder = QDir(m_mailRoot).absoluteFilePath(
+ account.maildir + QLatin1Char('/') + account.drafts);
+ // A path comparison with a separator, never startsWith() on the bare
+ // folder: "/mail/acct/Drafts-old/cur/x" starts with "/mail/acct/Drafts"
+ // and is a different folder. This is the rule the attachment save path
+ // already follows.
+ if (absolute.startsWith(folder + QLatin1Char('/')))
+ return true;
+ }
+ return false;
+}
+
QString MainWindow::accountForCurrentMessage() const
{
if (m_mailRoot.isEmpty())
@@ -1214,6 +1311,17 @@ void MainWindow::updateComposeActions()
action->setEnabled(canReply);
}
+ // Edit draft is offered only on a row that IS a draft. On ordinary mail
+ // it would open a composer owning a file it did not write, and the first
+ // autosave replaces that file: editing a received message would delete it.
+ //
+ // Answered from the path, like accountForCurrentMessage() above, because
+ // the folder is what makes a draft a draft. 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 rather than in the drafts folder.
+ if (QAction *edit = m_actions.value(QStringLiteral("edit_draft")))
+ edit->setEnabled(currentMessageIsADraft());
+
// The ribbon appears only when an account was identified AND it cannot
// send. An unidentified account is not a receive-only one: it is a message
// whose file no account owns, and naming no account in a ribbon that
@@ -1717,6 +1825,9 @@ void MainWindow::registerActions()
addAction(QStringLiteral("forward"), tr("&Forward"),
tr("Forward the displayed message"),
[this]() { composeReply(ComposeContext::Kind::Forward, true); });
+ addAction(QStringLiteral("edit_draft"), tr("&Edit draft"),
+ tr("Open the selected draft in a composer to finish it"),
+ [this]() { editDraft(); });
addAction(QStringLiteral("save_message"), tr("Sa&ve message as..."),
tr("Write the raw message to a file"),
[this]() { saveDisplayedMessage(); });
@@ -1764,6 +1875,7 @@ void MainWindow::buildMenus()
messageMenu->addAction(m_actions.value(QStringLiteral("reply_all")));
messageMenu->addAction(m_actions.value(QStringLiteral("reply_no_quote")));
messageMenu->addAction(m_actions.value(QStringLiteral("forward")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("edit_draft")));
messageMenu->addSeparator();
messageMenu->addAction(m_actions.value(QStringLiteral("save_message")));
messageMenu->addSeparator();
@@ -1875,6 +1987,7 @@ void MainWindow::buildMenus()
{ QStringLiteral("select_all"), QStringLiteral("edit-select-all") },
{ QStringLiteral("clear_pane"), QStringLiteral("edit-clear") },
{ QStringLiteral("clear_selection"), QStringLiteral("edit-clear-all") },
+ { QStringLiteral("edit_draft"), QStringLiteral("document-edit") },
{ QStringLiteral("toggle_html"), QStringLiteral("text-html") },
{ QStringLiteral("load_remote"), QStringLiteral("image-loading") },
{ QStringLiteral("message_details"), QStringLiteral("dialog-information") },
@@ -4111,6 +4224,16 @@ void MainWindow::onRowDoubleClicked(const QModelIndex &index)
if (!index.isValid())
return;
+ // A draft opens in the COMPOSER, not in a thread view of itself: it is an
+ // unfinished message, and looking at one rendered is not what the gesture
+ // means (item 153). Checked before the thread route below, which is what
+ // every other row does.
+ // The CLICKED row, which is not necessarily the current one.
+ if (indexIsADraft(index)) {
+ editDraftAt(index);
+ return;
+ }
+
// The whole thread in every case, and the double-clicked row's own message
// in the pane. A reply therefore drills to its THREAD with itself selected,
// never to itself alone: "double click on a reply in a thread should still
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 909b04b..4b1ef8f 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -722,6 +722,19 @@ private:
/// model's paths and tags come from the query, so a row that has not been
/// re-queried carries stale values and a reply built from one would go to
/// the wrong recipients.
+ /// Opens the selected draft in a composer to finish it (item 153).
+ /// Whether the current row's file sits in a configured drafts folder.
+ bool currentMessageIsADraft() const;
+
+ /// Whether \p index names a file in a configured drafts folder.
+ bool indexIsADraft(const QModelIndex &index) const;
+
+ void editDraft();
+
+ /// Opens the draft named by \p index, which double-click uses:
+ /// the clicked row is not necessarily the current one.
+ void editDraftAt(const QModelIndex &index);
+
void requestMessageForCompose(const QString &messageId,
ComposeContext::Kind kind, bool quote);
diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp
index c1198b8..6a7c5b1 100644
--- a/src/mimeparser.cpp
+++ b/src/mimeparser.cpp
@@ -415,6 +415,7 @@ ParsedMessage MimeParser::parse(const QString &filePath) const
out.replyTo = headerText(message, "Reply-To");
out.to = headerText(message, "To");
out.cc = headerText(message, "Cc");
+ out.bcc = headerText(message, "Bcc");
out.date = headerText(message, "Date");
out.references = headerText(message, "References");
out.messageId = QString::fromUtf8(
diff --git a/src/mimeparser.h b/src/mimeparser.h
index 64c4585..536b781 100644
--- a/src/mimeparser.h
+++ b/src/mimeparser.h
@@ -130,6 +130,13 @@ struct ParsedMessage
QString to;
QString cc;
+
+ /// Present only on a message this application wrote: a DRAFT or a sent
+ /// copy carries its Bcc list in the file (see messagebuilder.cpp, which
+ /// explains why), and a resumed draft has to read it back or a blind
+ /// recipient is silently dropped from the message the user finishes.
+ /// Received mail does not carry one.
+ QString bcc;
QString date;
QString messageId;
diff --git a/src/types.h b/src/types.h
index 99c271d..7464586 100644
--- a/src/types.h
+++ b/src/types.h
@@ -247,7 +247,7 @@ struct DatabaseStats
/// recipients. This is the same rule Restore already follows.
struct ComposeContext
{
- enum class Kind { New, Reply, ReplyAll, Forward };
+ enum class Kind { New, Reply, ReplyAll, Forward, Draft };
QString accountKey; ///< Which account sends. Plain data here; the resolution rules live with whatever builds this context.
Kind kind = Kind::New;
@@ -256,8 +256,23 @@ struct ComposeContext
QStringList references; ///< The original's References plus its Message-ID.
QStringList to; ///< Pre-filled, the user's own addresses already stripped.
QStringList cc;
+ QStringList bcc; ///< Only a resumed draft has one; see MimeParser::bcc.
QString subject; ///< Re:/Fwd: prefixed, an existing prefix not doubled.
QString quotedBody; ///< The >-prefixed original. Empty when the action does not quote.
+
+ /// The body as the user last left it, for a resumed draft ONLY.
+ ///
+ /// Separate from quotedBody because it is not a quote and must not be
+ /// framed like one: no attribution, no blank lines added, no cursor moved
+ /// to make room. It is the message itself.
+ QString body;
+
+ /// The draft file this composer OWNS, empty for every other kind.
+ ///
+ /// Seeded into ComposeWindow::m_draftPath so the next autosave REPLACES
+ /// the file rather than leaving the original beside it. Without it a
+ /// resumed draft becomes two drafts on the first autosave.
+ QString draftPath;
bool seedHtml = false; ///< Did the original carry a text/html part.
QStringList attachments; ///< Carried forward for Forward, empty otherwise.
};
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 3dbb227..a6aa7f5 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -53,6 +53,9 @@
#include "carddelegate.h"
#include "composewindow.h"
#include "senddialog.h"
+#include "composecontext.h"
+#include "messagebuilder.h"
+#include "draftstore.h"
#include "messagesender.h"
#include <QCheckBox>
#include <QPlainTextEdit>
@@ -118,6 +121,9 @@ public:
QString trash;
QString sendCommand;
QString address;
+ /// Written only when non-empty, like trash: an account without one
+ /// offers no Drafts filter and no Edit draft (items 138 and 153).
+ QString drafts;
};
/// Writes several accounts, for the compose cases.
@@ -194,6 +200,8 @@ public:
// send_command is receive-only, which is the shape under test.
if (!account.sendCommand.isEmpty())
out << "send_command=" << account.sendCommand << "\n";
+ if (!account.drafts.isEmpty())
+ out << "drafts=" << account.drafts << "\n";
}
}
file.close();
@@ -478,6 +486,11 @@ private slots:
// fixture.
void aComposerOpensClean();
void ctrlWClosesTheComposer();
+ void aDraftReopensWithItsOwnContent();
+ void editDraftIsOfferedOnlyForADraft();
+ void doubleClickingADraftOpensTheComposer();
+ void aResumedDraftReplacesItsFileRatherThanAddingOne();
+ void aResumedDraftKeepsItsBlindRecipients();
void theComposerSplitsItsToolbarByScope();
void ccAndBccHideBehindADisclosure();
void ccAndBccAreRevealedWhenTheyCarryAValue();
@@ -12449,6 +12462,282 @@ void TestMainWindow::removeAttachmentAppearsOnlyWithAttachments()
"Remove attachment is not offered with a file attached");
}
+namespace {
+
+/// Writes a draft the way ComposeWindow's autosave does, and returns its path.
+///
+/// Built through MessageBuilder rather than by hand, so the test resumes the
+/// bytes the application really writes: a draft assembled from a string
+/// literal could disagree with the builder and the round trip would prove
+/// nothing about the real file.
+QString writeDraftFile(const QString &folder, const OutgoingMessage &message,
+ const Account &account)
+{
+ QDir().mkpath(folder + QStringLiteral("/cur"));
+ QDir().mkpath(folder + QStringLiteral("/new"));
+ QDir().mkpath(folder + QStringLiteral("/tmp"));
+ const MessageBuilder::Result built = MessageBuilder::build(message, account);
+ if (!built.ok()) {
+ qWarning("draft fixture: build failed: %s", qPrintable(built.error));
+ return {};
+ }
+ const DraftStore::Result written =
+ DraftStore::write(folder, built.bytes, QStringLiteral("D"));
+ if (!written.ok())
+ qWarning("draft fixture: write failed: %s", qPrintable(written.error));
+ return written.path;
+}
+
+} // namespace
+
+void TestMainWindow::doubleClickingADraftOpensTheComposer()
+{
+ // The user's own words: "Double clicking on a draft should open the
+ // message in the editor window." Every other row opens its thread, which
+ // for an unfinished message means looking at it rendered and being unable
+ // to touch it.
+ WorkerComposeFixture fixture;
+ QVERIFY(fixture.backed.fixture().addMessage(
+ QStringLiteral("acct/Drafts"), QStringLiteral("draft2@example.org"),
+ QStringLiteral("Unfinished"), QStringLiteral("you@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Half a thought.")));
+ QVERIFY2(fixture.seed({ { QStringLiteral("acct"), QStringLiteral("acct"),
+ QStringLiteral("Trash"),
+ QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org"),
+ QStringLiteral("Drafts") } },
+ QStringLiteral("acct/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(model && view && queryEdit);
+
+ queryEdit->setText(QStringLiteral("id:draft2@example.org"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1
+ && !window.mailRootForTesting().isEmpty(),
+ 15000);
+
+ const QModelIndex row = model->index(0, 0, QModelIndex());
+ view->setCurrentIndex(row);
+
+ const int before = window.openComposerCount();
+ emit view->doubleClicked(row);
+
+ // Through the worker, so the composer arrives on a later turn.
+ QTRY_VERIFY_WITH_TIMEOUT(
+ window.openComposerCount() == before + 1, 15000);
+}
+
+void TestMainWindow::editDraftIsOfferedOnlyForADraft()
+{
+ // A draft renders like any other message, so the action has to say which
+ // rows it applies to. Offered on ordinary mail it would open a composer
+ // that owns a file it did not write, and the first autosave would then
+ // delete a received message.
+ WorkerComposeFixture fixture;
+ // The draft goes in BEFORE the window opens: the worker holds the database
+ // open, so a message added afterwards is not in the index it queries.
+ QVERIFY(fixture.backed.fixture().addMessage(
+ QStringLiteral("acct/Drafts"), QStringLiteral("draft1@example.org"),
+ QStringLiteral("Half written"), QStringLiteral("you@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body.")));
+ QVERIFY2(fixture.seed({ { QStringLiteral("acct"), QStringLiteral("acct"),
+ QStringLiteral("Trash"),
+ QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org"),
+ QStringLiteral("Drafts") } },
+ QStringLiteral("acct/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ auto *edit = window.findChild<QAction *>(QStringLiteral("edit_draft"));
+ QVERIFY(model && view && queryEdit && edit);
+
+ // Selected by id, not through selectTheMessage(): there are two messages
+ // here, and this test is about which FOLDER each sits in.
+ const auto selectById = [&](const QString &id) {
+ queryEdit->setText(QStringLiteral("id:") + id);
+ 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;
+ };
+
+ QVERIFY2(selectById(QStringLiteral("compose1@example.org")),
+ "the inbox message was not found");
+ QVERIFY2(!edit->isEnabled(),
+ "Edit draft is offered on a message in the inbox");
+
+ // The guard, and it is the half that matters: an action disabled
+ // everywhere passes the assertion above while the feature does not exist.
+ QVERIFY2(selectById(QStringLiteral("draft1@example.org")),
+ "the draft was not found");
+ QVERIFY2(edit->isEnabled(),
+ "Edit draft is not offered on a message in the drafts folder");
+}
+
+void TestMainWindow::aDraftReopensWithItsOwnContent()
+{
+ // Item 153. A draft was write-only: DraftStore had a write() and no
+ // reader, and nothing opened a composer from an existing message, so a
+ // draft rendered as ordinary mail and could never be finished.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ OutgoingMessage message;
+ message.accountKey = QStringLiteral("acct");
+ message.to = { QStringLiteral("someone@example.org") };
+ message.cc = { QStringLiteral("copied@example.org") };
+ message.subject = QStringLiteral("A half-written note");
+ message.markdownBody = QStringLiteral("The first half.\n\nAnd more.");
+
+ const QString folder = fixture.mailRoot() + QStringLiteral("/acct/Drafts");
+ const QString path = writeDraftFile(folder, message,
+ fixture.config().account(
+ QStringLiteral("acct")));
+ QVERIFY2(!path.isEmpty(), "the draft fixture was not written");
+
+ ComposeContext context =
+ ComposeContextBuilder::forDraft(fixture.config(), path);
+ QVERIFY2(context.kind == ComposeContext::Kind::Draft,
+ "forDraft did not produce a Draft context");
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *to = window.findChild<QLineEdit *>(QStringLiteral("to"));
+ auto *cc = window.findChild<QLineEdit *>(QStringLiteral("cc"));
+ auto *subject = window.findChild<QLineEdit *>(QStringLiteral("subject"));
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(to && cc && subject && body);
+
+ QVERIFY2(to->text().contains(QStringLiteral("someone@example.org")),
+ qPrintable(QStringLiteral("To reads '%1'").arg(to->text())));
+ QVERIFY2(cc->text().contains(QStringLiteral("copied@example.org")),
+ qPrintable(QStringLiteral("Cc reads '%1'").arg(cc->text())));
+ QCOMPARE(subject->text(), QStringLiteral("A half-written note"));
+
+ // The body VERBATIM: no attribution, no quote markers, and no blank lines
+ // added. A draft is the message itself, not something being answered, so
+ // seedBody()'s quote framing must not touch it.
+ QVERIFY2(body->toPlainText().contains(QStringLiteral("The first half.")),
+ qPrintable(QStringLiteral("the body reads '%1'")
+ .arg(body->toPlainText())));
+ QVERIFY2(!body->toPlainText().contains(QStringLiteral("wrote:")),
+ "the draft body was framed as a quote");
+ QVERIFY2(!body->toPlainText().startsWith(QLatin1Char('>')),
+ "the draft body was quote-marked");
+ QVERIFY2(!body->toPlainText().startsWith(QStringLiteral("\n\n")),
+ "blank lines were prepended to a draft, as if it were a reply");
+}
+
+void TestMainWindow::aResumedDraftReplacesItsFileRatherThanAddingOne()
+{
+ // The half that makes resuming safe rather than merely possible. Maildir
+ // has no in-place edit, so an autosave writes a new file and unlinks the
+ // old one; a resumed draft that did not know its own path would leave the
+ // original behind and the user would have two drafts of one message.
+ 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.");
+
+ 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.draftPath, path);
+
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ const auto draftCount = [&folder]() {
+ return QDir(folder + QStringLiteral("/cur"))
+ .entryList(QDir::Files).size();
+ };
+ QCOMPARE(draftCount(), 1);
+
+ body->setPlainText(QStringLiteral("Body, continued."));
+
+ // Through the real timer, which is what production uses: the edit above
+ // starts it, and firing it here runs the same autosave() a pause would.
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("autosave"));
+ QVERIFY2(timer, "the composer has no autosave timer");
+ QVERIFY2(timer->isActive(),
+ "editing the body did not arm the autosave timer");
+ timer->setInterval(0);
+ QTRY_VERIFY_WITH_TIMEOUT(!timer->isActive(), 5000);
+
+ QCOMPARE(draftCount(), 1);
+ QVERIFY2(!QFile::exists(path),
+ "the original draft file survived the autosave, so the message "
+ "now exists twice");
+}
+
+void TestMainWindow::aResumedDraftKeepsItsBlindRecipients()
+{
+ // MessageBuilder writes Bcc into the draft file deliberately, and says
+ // why. A resumed draft that did not read it back would drop every blind
+ // recipient silently: the user finishes the message, sends it, and the
+ // people they addressed blindly never receive it and nothing reports so.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build());
+
+ OutgoingMessage message;
+ message.accountKey = QStringLiteral("acct");
+ message.to = { QStringLiteral("someone@example.org") };
+ message.bcc = { QStringLiteral("blind@example.org") };
+ message.subject = QStringLiteral("With a blind copy");
+ message.markdownBody = QStringLiteral("Body.");
+
+ 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);
+ ComposeWindow window(context, fixture.config(), fixture.mailRoot());
+
+ auto *bcc = window.findChild<QLineEdit *>(QStringLiteral("bcc"));
+ QVERIFY(bcc);
+ QVERIFY2(bcc->text().contains(QStringLiteral("blind@example.org")),
+ qPrintable(QStringLiteral("Bcc reads '%1', so a blind recipient "
+ "was dropped").arg(bcc->text())));
+
+ // And it is VISIBLE, per item 145: a hidden field holding an address is a
+ // message going somewhere the sender cannot see.
+ QVERIFY2(!bcc->isHidden(),
+ "the resumed draft hid a Bcc it actually carries");
+}
+
void TestMainWindow::ctrlWClosesTheComposer()
{
// Item 148. Ctrl+W closes a window in every application the user runs, and
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index 9a8f7cf..f34b493 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -620,6 +620,14 @@ Il messaggio È stato inviato. Non inviarlo di nuovo.</translation>
<translation>Nessun messaggio selezionato</translation>
</message>
<message>
+ <source>That draft could not be read</source>
+ <translation>Non è stato possibile leggere quella bozza</translation>
+ </message>
+ <message>
+ <source>No account is configured to send</source>
+ <translation>Nessun account è configurato per inviare</translation>
+ </message>
+ <message>
<source>That message could not be read</source>
<translation>Impossibile leggere quel messaggio</translation>
</message>
@@ -848,6 +856,14 @@ Il messaggio È stato inviato. Non inviarlo di nuovo.</translation>
<translation>Inoltra il messaggio visualizzato</translation>
</message>
<message>
+ <source>&amp;Edit draft</source>
+ <translation>&amp;Modifica bozza</translation>
+ </message>
+ <message>
+ <source>Open the selected draft in a composer to finish it</source>
+ <translation>Apri la bozza selezionata in un editor per completarla</translation>
+ </message>
+ <message>
<source>Write the raw message to a file</source>
<translation>Scrive il messaggio grezzo su un file</translation>
</message>