aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
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 &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.
};