diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/composecontext.cpp | 63 | ||||
| -rw-r--r-- | src/composecontext.h | 13 | ||||
| -rw-r--r-- | src/composewindow.cpp | 29 | ||||
| -rw-r--r-- | src/keymap.cpp | 1 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 123 | ||||
| -rw-r--r-- | src/mainwindow.h | 13 | ||||
| -rw-r--r-- | src/mimeparser.cpp | 1 | ||||
| -rw-r--r-- | src/mimeparser.h | 7 | ||||
| -rw-r--r-- | src/types.h | 17 |
9 files changed, 262 insertions, 5 deletions
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 ¤t) 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. }; |
