aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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>