aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-25 09:13:21 +0200
committerDanilo M. <danix@danix.xyz>2026-08-25 09:13:21 +0200
commit3e196bbaf6a5f3ebf6597e0ada1f559d24627ca6 (patch)
tree5d76949bf34ab581826f0f699d60e0fc45b3e175 /src
parentf8d136432466479c892841bc73bd85e58674da86 (diff)
parent0a26961f9a7ae6ab98051e182b92e64165758cf1 (diff)
downloadqtmaildir-3e196bbaf6a5f3ebf6597e0ada1f559d24627ca6.tar.gz
qtmaildir-3e196bbaf6a5f3ebf6597e0ada1f559d24627ca6.zip
Merge branch 'signatures'
Signatures (item 152): one markdown file per signature under ~/.config/qtmaildir/signatures/, spliced into the composer buffer and chosen from a switch on the editor bar. [compose] signature seeds a new message, [account.<key>] signature overrides per account, and [compose] signature_position picks end or above_quote. Also carries three fixes found by hand-testing it: a saved draft is indexed so it appears without a sync (item 158), the Drafts filter lists messages rather than threads so a draft reply can be opened (item 159), and a resumed draft no longer re-seeds its signature on a From: change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/composewindow.cpp168
-rw-r--r--src/composewindow.h44
-rw-r--r--src/config.cpp73
-rw-r--r--src/config.h23
-rw-r--r--src/mainwindow.cpp11
-rw-r--r--src/notmuchworker.cpp93
-rw-r--r--src/notmuchworker.h22
-rw-r--r--src/signatures.cpp229
-rw-r--r--src/signatures.h76
10 files changed, 727 insertions, 13 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 2cebfef..700b185 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -37,6 +37,7 @@ add_library(qtmaildir_lib STATIC
querycompleter.cpp
rulequery.cpp
searchterm.cpp
+ signatures.cpp
)
target_include_directories(qtmaildir_lib
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index a64736f..c35bb5d 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -25,6 +25,7 @@
#include "mimeparser.h"
#include "messagesender.h"
#include "senddialog.h"
+#include "signatures.h"
#include <QAction>
#include <QCheckBox>
@@ -40,9 +41,11 @@
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
+#include <QMenu>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
+#include <QStandardPaths>
#include <QTextCursor>
#include <QTimer>
#include <QToolBar>
@@ -128,6 +131,7 @@ ComposeWindow::ComposeWindow(const ComposeContext &context,
buildFormatToolbar();
seedFields();
seedBody();
+ seedSignature();
// AFTER buildUi(), which creates m_banner, and BEFORE
// refreshAttachmentList(), which renders m_attachments: extraction appends
@@ -402,8 +406,22 @@ void ComposeWindow::buildUi()
for (QLineEdit *field : { m_to, m_cc, m_bcc, m_subject })
connect(field, &QLineEdit::textChanged, this, &ComposeWindow::markDirty);
connect(m_sendHtml, &QCheckBox::toggled, this, &ComposeWindow::markDirty);
- connect(m_from, &QComboBox::currentIndexChanged, this,
- &ComposeWindow::markDirty);
+ connect(m_from, &QComboBox::currentIndexChanged, this, [this]() {
+ markDirty();
+ // The account SEEDS the signature, so a change to it re-seeds. It
+ // stops the moment the user picks one: re-seeding unconditionally is
+ // the one behaviour that can silently discard a deliberate choice
+ // made a moment earlier. Same shape as send_html, which seeds from
+ // context and is then left alone.
+ if (m_signatureChosen)
+ return;
+ const QString seeded = seededSignatureName();
+ if (!Signatures::names(m_signatureDir).contains(seeded)) {
+ applySignature(QString());
+ return;
+ }
+ applySignature(seeded);
+ });
}
void ComposeWindow::buildFormatToolbar()
@@ -536,6 +554,29 @@ void ComposeWindow::buildFormatToolbar()
m_sendHtml->setIcon(htmlIcon);
m_formatToolbar->addWidget(m_sendHtml);
+ // The signature switch rides at the right end with Attach and the HTML
+ // toggle: item 142 put the controls OF THE EDITOR on this side, as against
+ // the formatting buttons on the left, and choosing a signature is one of
+ // those.
+ //
+ // A QToolButton with a menu rather than a QComboBox, matching the bar's
+ // other controls; a combo would read as a different class of thing. Not
+ // registered in KeyMap: it is parented to this window, exactly as the
+ // formatting actions are, so its scope is the composer and item 132's
+ // reachability rule does not apply.
+ m_signatureSwitch = new QToolButton(m_formatToolbar);
+ m_signatureSwitch->setObjectName(QStringLiteral("signatureSwitch"));
+ m_signatureSwitch->setText(tr("Signature"));
+ m_signatureSwitch->setToolTip(
+ tr("Chooses the signature added to this message."));
+ m_signatureSwitch->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+ m_signatureSwitch->setPopupMode(QToolButton::InstantPopup);
+ const QIcon signatureIcon = QIcon::fromTheme(QStringLiteral("insert-text"));
+ if (!signatureIcon.isNull())
+ m_signatureSwitch->setIcon(signatureIcon);
+ m_signatureSwitch->setMenu(new QMenu(m_signatureSwitch));
+ m_formatToolbar->addWidget(m_signatureSwitch);
+
// Send is NOT on this row: it is the terminal action, and it lives on the
// button beside the headers. The QAction survives because it carries the
// shortcut and is what the button triggers.
@@ -664,6 +705,122 @@ void ComposeWindow::seedBody()
m_body->document()->clearUndoRedoStacks();
}
+void ComposeWindow::setSignatureDir(const QString &dir)
+{
+ m_signatureDir = dir;
+}
+
+QStringList ComposeWindow::knownSignatures() const
+{
+ QStringList known;
+ const QStringList names = Signatures::names(m_signatureDir);
+ known.reserve(names.size());
+ for (const QString &name : names)
+ known.append(Signatures::text(m_signatureDir, name));
+ return known;
+}
+
+QString ComposeWindow::seededSignatureName() const
+{
+ // The COMBO, not m_context: the context records where the composer opened
+ // and does not follow a From: change, so reading it would seed the
+ // original account's signature for ever.
+ const QString key = m_from->currentData().toString();
+ const Account account =
+ m_config.account(key.isEmpty() ? m_context.accountKey : key);
+ if (!account.signature.isEmpty())
+ return account.signature;
+ return m_config.compose().signature;
+}
+
+void ComposeWindow::applySignature(const QString &name)
+{
+ const QString text =
+ name.isEmpty() ? QString() : Signatures::text(m_signatureDir, name);
+
+ // A QTextCursor replacement rather than setPlainText(), for the reason
+ // recorded at applyEdit(): setPlainText() destroys the document's undo
+ // stack, so a switch would make everything typed before it unrecoverable.
+ const QString replaced = Signatures::replace(
+ m_body->toPlainText(), text, knownSignatures(),
+ m_config.compose().signaturePosition);
+
+ QTextCursor cursor(m_body->document());
+ cursor.select(QTextCursor::Document);
+ cursor.insertText(replaced);
+
+ m_signatureName = name;
+
+ for (QAction *action : m_signatureSwitch->menu()->actions())
+ action->setChecked(action->data().toString() == name);
+}
+
+void ComposeWindow::seedSignature()
+{
+ if (m_signatureDir.isEmpty()) {
+ const QString base =
+ QStandardPaths::writableLocation(QStandardPaths::ConfigLocation);
+ m_signatureDir = base + QStringLiteral("/qtmaildir/signatures");
+ }
+
+ QMenu *menu = m_signatureSwitch->menu();
+ menu->clear();
+
+ auto *none = menu->addAction(tr("None"));
+ none->setCheckable(true);
+ none->setData(QString());
+ connect(none, &QAction::triggered, this, [this]() {
+ m_signatureChosen = true;
+ applySignature(QString());
+ markDirty();
+ });
+
+ const QStringList names = Signatures::names(m_signatureDir);
+ for (const QString &name : names) {
+ auto *action = menu->addAction(name);
+ action->setCheckable(true);
+ action->setData(name);
+ connect(action, &QAction::triggered, this, [this, name]() {
+ m_signatureChosen = true;
+ applySignature(name);
+ markDirty();
+ });
+ }
+
+ // A resumed draft is the message ITSELF and already carries whatever
+ // signature it was saved with, exactly as seedBody() takes its body
+ // verbatim. Seeding again would append a second one.
+ if (m_context.kind == ComposeContext::Kind::Draft) {
+ none->setChecked(true);
+ // The draft IS the user's choice: its signature is deliberate prior
+ // state, so a From: change must not follow the new account and
+ // rewrite what was saved. Marking it chosen keeps the same invariant
+ // the switch actions set, without ever having run the switch.
+ m_signatureChosen = true;
+ return;
+ }
+
+ const QString seeded = seededSignatureName();
+ if (seeded.isEmpty()) {
+ none->setChecked(true);
+ return;
+ }
+ if (!names.contains(seeded)) {
+ // Reported by Config as a problem; the composer still opens, with no
+ // signature, and the switch still works.
+ none->setChecked(true);
+ return;
+ }
+
+ applySignature(seeded);
+
+ // The seeded signature is not an edit the user made, so it must not
+ // survive as an undo step: one Ctrl+Z on a fresh composer would otherwise
+ // wipe content they never typed. Same reason seedBody() clears after the
+ // quote.
+ m_body->document()->clearUndoRedoStacks();
+}
+
void ComposeWindow::refreshAttachmentList()
{
m_attachmentList->clear();
@@ -861,6 +1018,7 @@ bool ComposeWindow::saveDraftNow()
const QString folder = QDir(m_mailRoot).absoluteFilePath(
account.maildir + QLatin1Char('/') + account.drafts);
+ const QString previousPath = m_draftPath;
const DraftStore::Result written =
DraftStore::write(folder, built.bytes, QStringLiteral("D"), m_draftPath);
@@ -881,6 +1039,10 @@ bool ComposeWindow::saveDraftNow()
m_dirty = false;
m_saveFailed = false;
m_banner->hide();
+
+ // The write is done and the previous revision already unlinked; hand both
+ // paths up so the owner indexes the new one and drops the old (item 158).
+ emit draftSaved(written.path, previousPath);
return true;
}
@@ -897,6 +1059,7 @@ void ComposeWindow::setInputsEnabled(bool enabled)
m_from->setEnabled(enabled);
m_body->setReadOnly(!enabled);
m_sendHtml->setEnabled(enabled);
+ m_signatureSwitch->setEnabled(enabled);
m_attachmentList->setEnabled(enabled);
m_formatToolbar->setEnabled(enabled);
@@ -1042,6 +1205,7 @@ void ComposeWindow::send()
dialog->setStage(SendDialog::Stage::RemovingDraft);
if (!m_draftPath.isEmpty()) {
QFile::remove(m_draftPath);
+ emit draftRemoved(m_draftPath);
m_draftPath.clear();
}
diff --git a/src/composewindow.h b/src/composewindow.h
index c44fcda..caff011 100644
--- a/src/composewindow.h
+++ b/src/composewindow.h
@@ -96,6 +96,21 @@ public:
/// and quitting therefore loses that text.
bool lastSaveFailed() const { return m_saveFailed; }
+ /// Where the signature files live. Defaults to
+ /// <config>/qtmaildir/signatures; a test points it at its own directory.
+ ///
+ /// A setter rather than a config key: nothing yet suggests the user wants
+ /// a second location, and the tests need to not read the real one.
+ void setSignatureDir(const QString &dir);
+
+ /// Seeds the signature from config and fills the switch.
+ ///
+ /// Public and called by the constructor rather than private, so a test can
+ /// drive it after pointing setSignatureDir() somewhere safe. A resumed
+ /// draft seeds nothing: its body already carries the signature it was
+ /// written with.
+ void seedSignature();
+
/// Writes the current buffer to the drafts folder now. Returns false and
/// leaves the banner up on failure.
///
@@ -153,6 +168,17 @@ signals:
/// pointer before WA_DeleteOnClose destroys the window.
void closed(ComposeWindow *window);
+ /// A draft was written to disk, so the window's owner can index it and it
+ /// appears in the Drafts view without a full sync (item 158).
+ ///
+ /// \p path is the file just written, absolute. \p previousPath is the file
+ /// the write replaced, empty on the first save of a new draft.
+ void draftSaved(const QString &path, const QString &previousPath);
+
+ /// A draft file was unlinked (sent), so its index entry must go too.
+ /// \p path is the file that was removed, absolute.
+ void draftRemoved(const QString &path);
+
protected:
/// The one place the registry is told, whichever route closes the window.
void closeEvent(QCloseEvent *event) override;
@@ -176,6 +202,16 @@ private:
/// a silently wrong send is not among the outcomes.
void extractForwardedAttachments();
void seedBody();
+
+ /// Applies \p name to the buffer, replacing whatever is there.
+ void applySignature(const QString &name);
+
+ /// The text of every signature on disk, for replace()'s guard.
+ QStringList knownSignatures() const;
+
+ /// The signature name this account seeds, falling through to [compose].
+ QString seededSignatureName() const;
+
void refreshAttachmentList();
void setInputsEnabled(bool enabled);
void showSendFailure(const QString &stderrText);
@@ -227,6 +263,14 @@ private:
QComboBox *m_from = nullptr;
QPlainTextEdit *m_body = nullptr;
QToolButton *m_sendHtml = nullptr;
+ QToolButton *m_signatureSwitch = nullptr;
+ QString m_signatureDir;
+ QString m_signatureName; ///< The selected signature, empty for None.
+
+ /// True once the user has used the switch. From then on a From: change
+ /// stops re-seeding, so a deliberate choice is never overwritten. Matches
+ /// how send_html seeds from context and is then left alone.
+ bool m_signatureChosen = false;
QLabel *m_banner = nullptr;
QListWidget *m_attachmentList = nullptr;
QWidget *m_sendLogPane = nullptr;
diff --git a/src/config.cpp b/src/config.cpp
index 23c7364..d91259a 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -80,6 +80,18 @@ QString generatorTag(const QString &generator)
return QString();
}
+/// Whether a generator lists MESSAGES rather than threads. "sent" folds a
+/// user's own message back into the conversation it answers, and "drafts" is
+/// worse: a thread row stands for its first matched message, which for a draft
+/// reply is the message being replied TO, so the draft itself is unreachable.
+/// "trash" stays threaded, since a deleted message still belongs to its
+/// conversation. Closed set, and the one place the three views are decided.
+bool generatorIsFlat(const QString &generator)
+{
+ return generator == QStringLiteral("sent")
+ || generator == QStringLiteral("drafts");
+}
+
} // namespace
QString Account::scopedQuery(const QString &query) const
@@ -459,6 +471,17 @@ void Config::load(const QString &path)
account.sent =
settings.value(QStringLiteral("sent")).toString().trimmed();
+ // Optional, and a STARTING value rather than a binding: the composer's
+ // switch keeps every signature reachable whichever account is
+ // selected. Left empty when absent, so the composer can tell "this
+ // account says nothing" from "this account says none" and fall through
+ // to [compose] signature itself; resolving that here would collapse
+ // the two. Trimmed for the same reason as sent, above: a trailing
+ // space would be carried into a filename lookup and match nothing,
+ // which is invisible in a config file.
+ account.signature =
+ settings.value(QStringLiteral("signature")).toString().trimmed();
+
// Mandatory, unlike sent: Delete moves a file into this folder, so an
// account without one cannot delete at all. Trimmed for the same
// reason as sent, above.
@@ -547,6 +570,31 @@ void Config::load(const QString &path)
m_compose.sendHtml =
settings.value(QStringLiteral("send_html"), true).toBool();
+ // Trimmed for the same reason the account key is: it reaches a filename
+ // lookup, where a trailing space matches nothing invisibly.
+ m_compose.signature =
+ settings.value(QStringLiteral("signature")).toString().trimmed();
+
+ // The same shape as quote_position directly above: an absent key is
+ // silent and the struct default holds, but a PRESENT and malformed value
+ // is reported rather than silently accepted. value(key, default) alone
+ // would read "signature_position = abov" as above_quote.
+ const QString signaturePosition =
+ settings.value(QStringLiteral("signature_position"),
+ QStringLiteral("end"))
+ .toString().trimmed();
+ if (signaturePosition.compare(QStringLiteral("above_quote"),
+ Qt::CaseInsensitive) == 0) {
+ m_compose.signaturePosition = Signatures::Position::AboveQuote;
+ } else if (signaturePosition.compare(QStringLiteral("end"),
+ Qt::CaseInsensitive) == 0) {
+ m_compose.signaturePosition = Signatures::Position::End;
+ } else {
+ addProblem(tr("[compose] signature_position '%1' is not recognised; "
+ "expected end or above_quote. Using end.")
+ .arg(signaturePosition));
+ }
+
// Three numerics, all following the shape already established at
// message_zoom, toolbar_icon_size, mark_read_delay_ms and
// auto_sync_delay_ms elsewhere in this function: a QVariant, a checked
@@ -802,14 +850,14 @@ void Config::loadSavedQueries(const QString &configPath, QSettings &settings)
query.query = object.value(QStringLiteral("query")).toString();
query.account = object.value(QStringLiteral("account")).toString();
query.generated = object.value(QStringLiteral("generated")).toString();
- // A generator carries its own view mode, so "sent" is flat whether or
- // not the file says so. Storing it as a plain field would let a
+ // A generator carries its own view mode, so a flat one is flat whether
+ // or not the file says so. Storing it as a plain field would let a
// hand-edited or migrated-from-elsewhere row produce a THREADED sent
// view, which folds every reply back into the conversation the user
// sent one message into. The file may still set it for an ordinary
// query.
query.flat = object.value(QStringLiteral("flat")).toBool(false)
- || query.generated == QStringLiteral("sent");
+ || generatorIsFlat(query.generated);
if (query.isGenerated()
&& !kQueryGenerators.contains(query.generated)) {
@@ -869,7 +917,7 @@ bool Config::saveSavedQueries() const
object.insert(QStringLiteral("account"), query.account);
// Skipped when the generator already implies it, which loadSavedQueries
// reapplies on the way back in.
- if (query.flat && query.generated != QStringLiteral("sent"))
+ if (query.flat && !generatorIsFlat(query.generated))
object.insert(QStringLiteral("flat"), true);
for (auto it = query.unknown.begin(); it != query.unknown.end(); ++it)
object.insert(it.key(), it.value());
@@ -951,6 +999,9 @@ SavedQuery Config::builtinFilter(const QString &generator)
SavedQuery filter;
filter.generated = generator;
+ // One source for the view mode, shared with the saved-query round trip, so
+ // a branch below cannot disagree with what loadSavedQueries reapplies.
+ filter.flat = generatorIsFlat(generator);
// Translated, because these are the labels on the buttons. The GENERATOR
// name is not: it is stored in queries.json and matched against a closed
@@ -969,16 +1020,18 @@ SavedQuery Config::builtinFilter(const QString &generator)
filter.name = tr("Important");
} else if (generator == QStringLiteral("sent")) {
filter.name = tr("Sent");
- // Messages rather than threads, and the only filter that sets this. A
- // thread would fold the user's sent message back into the conversation
- // it belongs to, which is item 63's finding.
- filter.flat = true;
+ // Flat, per generatorIsFlat(): a thread would fold the user's sent
+ // message back into the conversation it belongs to, item 63's finding.
} else if (generator == QStringLiteral("drafts")) {
// The LABEL is translated; the generator stays `drafts`, which is what
// queries.json stores and what a closed set is matched against.
filter.name = tr("Drafts");
- // NOT flat, like Trash and unlike Sent: a draft reply belongs with the
- // conversation it answers.
+ // Flat, per generatorIsFlat(). Item 138 chose threaded, reasoning that
+ // a draft reply belongs with the conversation it answers; item 159
+ // reversed it on what that cost. A thread row stands for its first
+ // MATCHED message, which for a draft reply is the message being
+ // replied TO, so the draft itself had no row of its own and
+ // double-clicking the conversation opened nothing.
} else if (generator == QStringLiteral("trash")) {
filter.name = tr("Trash");
// NOT flat, unlike Sent. A deleted message still belongs to its
diff --git a/src/config.h b/src/config.h
index 4dcfbf1..02b4038 100644
--- a/src/config.h
+++ b/src/config.h
@@ -26,6 +26,7 @@
#include <QStringList>
#include "completionentry.h"
+#include "signatures.h"
class QSettings;
@@ -58,6 +59,18 @@ struct Account
/// one for the account that has none.
QString sent;
+ /// The signature seeded when composing from this account, by name.
+ ///
+ /// Optional, and it does not tie a signature to the account: the switch on
+ /// the composer's editor bar keeps every signature reachable whichever
+ /// account is selected. This is a STARTING value only, which is why the
+ /// user's "not tied to an account" constraint survives it (item 152).
+ ///
+ /// The fallback to [compose] signature is NOT resolved here. An account
+ /// with no key of its own carries an empty string and the composer falls
+ /// through, so the two values stay distinguishable.
+ QString signature;
+
/// The account's trash folder, relative to maildir.
///
/// MANDATORY, unlike `sent` and `drafts`. Delete moves a file into this
@@ -228,6 +241,16 @@ struct ComposeSettings
/// accounts. Falls through when it names an account that cannot send.
QString defaultAccount;
+ /// The signature seeded when the account carries none, by name. Empty
+ /// means no signature is seeded at all.
+ QString signature;
+
+ /// Where a newly inserted signature goes. End by default, which is the
+ /// user's own habit; above_quote exists because other clients offer the
+ /// choice, and the splice's quote-aware scan is needed for the guard
+ /// either way.
+ Signatures::Position signaturePosition = Signatures::Position::End;
+
qint64 attachmentWarnBytes = 26214400;
};
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 99eb2a3..af3b817 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1138,6 +1138,17 @@ void MainWindow::openComposer(const ComposeContext &context)
});
});
+ // A saved draft is indexed immediately (item 158): the Drafts view is a
+ // path query, and without this the draft is invisible until the next sync.
+ // The worker lives on another thread, so this is a queued connection and
+ // notmuch stays on its own thread.
+ connect(composer, &ComposeWindow::draftSaved, m_worker,
+ &NotmuchWorker::indexDraftFile);
+
+ // A draft unlinked on send must leave no ghost entry behind.
+ connect(composer, &ComposeWindow::draftRemoved, m_worker,
+ &NotmuchWorker::removeIndexedFile);
+
composer->show();
}
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index fca0a5a..16df4ed 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -820,8 +820,99 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
emit messagesMovedFrom(origins, destFolder);
}
+void NotmuchWorker::indexDraftFile(const QString &path,
+ const QString &previousPath)
+{
+ if (path.isEmpty())
+ return;
+
+ // Same ordering as applyTags() and moveMessages(): notmuch allows one open
+ // handle per process, so the read-only one must close before the write.
+ close();
+
+ const QByteArray configPath = configPathArg();
+ notmuch_database_t *db = nullptr;
+ char *error = nullptr;
+ const notmuch_status_t status = notmuch_database_open_with_config(
+ nullptr,
+ NOTMUCH_DATABASE_MODE_READ_WRITE,
+ configPath.isEmpty() ? nullptr : configPath.constData(),
+ nullptr,
+ &db,
+ &error);
+
+ if (status != NOTMUCH_STATUS_SUCCESS) {
+ emit errorOccurred(
+ QStringLiteral("Cannot open database for writing: %1")
+ .arg(QString::fromUtf8(error ? error
+ : notmuch_status_to_string(status))));
+ free(error);
+ return;
+ }
+
+ notmuch_message_t *indexed = nullptr;
+ const notmuch_status_t added = notmuch_database_index_file(
+ db, path.toUtf8().constData(), nullptr, &indexed);
+ if (indexed)
+ notmuch_message_destroy(indexed);
+
+ // DUPLICATE_MESSAGE_ID is success here, exactly as in moveMessages(): the
+ // file reached the database, it is only the id that was already known.
+ if (added != NOTMUCH_STATUS_SUCCESS
+ && added != NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID) {
+ notmuch_database_close(db);
+ notmuch_database_destroy(db);
+ emit errorOccurred(
+ QStringLiteral("Cannot index %1: %2")
+ .arg(QFileInfo(path).fileName(),
+ QString::fromUtf8(notmuch_status_to_string(added))));
+ return;
+ }
+
+ // The previous revision, if any, is already unlinked from disk; its entry
+ // must not linger as a ghost draft with a filename that no longer exists.
+ if (!previousPath.isEmpty() && previousPath != path)
+ notmuch_database_remove_message(db, previousPath.toUtf8().constData());
+
+ notmuch_database_close(db);
+ notmuch_database_destroy(db);
+}
+
+void NotmuchWorker::removeIndexedFile(const QString &path)
+{
+ if (path.isEmpty())
+ return;
+
+ close();
+
+ const QByteArray configPath = configPathArg();
+ notmuch_database_t *db = nullptr;
+ char *error = nullptr;
+ const notmuch_status_t status = notmuch_database_open_with_config(
+ nullptr,
+ NOTMUCH_DATABASE_MODE_READ_WRITE,
+ configPath.isEmpty() ? nullptr : configPath.constData(),
+ nullptr,
+ &db,
+ &error);
+
+ if (status != NOTMUCH_STATUS_SUCCESS) {
+ emit errorOccurred(
+ QStringLiteral("Cannot open database for writing: %1")
+ .arg(QString::fromUtf8(error ? error
+ : notmuch_status_to_string(status))));
+ free(error);
+ return;
+ }
+
+ notmuch_database_remove_message(db, path.toUtf8().constData());
+
+ notmuch_database_close(db);
+ notmuch_database_destroy(db);
+}
+
void NotmuchWorker::resolveMessages(const QStringList &messageIds,
- const QString &requestTag)
+ const QString &requestTag)
{
if (messageIds.isEmpty())
return;
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 8ed878f..3ccf8e5 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -134,6 +134,28 @@ public slots:
/// it, so removing before indexing loses the message's tags.
void moveMessages(const QStringList &messageIds, const QString &destFolder);
+ /// Indexes one freshly written file, so it appears in a `path:` query
+ /// without a full `notmuch new` (item 158).
+ ///
+ /// The draft-save path writes the file and stops, and the Drafts view is a
+ /// path query, so an unindexed draft is invisible until the next sync. A
+ /// draft rewrite writes a NEW file (MessageBuilder generates a fresh
+ /// Message-ID on every build) and unlinks the old, so \p previousPath is
+ /// removed after the new one is indexed, mirroring moveMessages()'s
+ /// ordering: the old entry must not linger as a ghost draft.
+ ///
+ /// \p path is absolute, as DraftStore::write() returns it. The Maildir
+ /// flags on the file (the "D" flag a draft carries) drive its tags exactly
+ /// as they would on a later `notmuch new`.
+ void indexDraftFile(const QString &path, const QString &previousPath = {});
+
+ /// Removes one file from the index, without touching the file on disk.
+ ///
+ /// The counterpart to indexDraftFile() for the send path: a draft that was
+ /// indexed while being composed is unlinked when it is sent, and its entry
+ /// must not linger as a ghost draft until the next sync.
+ void removeIndexedFile(const QString &path);
+
/// Batch tagging over whole threads. The UI holds thread ids, not message
/// ids, for rows it has not opened, so the resolution happens here where
/// the database handle lives. This is the path the archive/flag/delete
diff --git a/src/signatures.cpp b/src/signatures.cpp
new file mode 100644
index 0000000..ef631d6
--- /dev/null
+++ b/src/signatures.cpp
@@ -0,0 +1,229 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "signatures.h"
+
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+
+namespace Signatures {
+
+QStringList names(const QString &dir)
+{
+ QDir directory(dir);
+ if (!directory.exists())
+ return {};
+
+ QStringList result;
+ const QStringList files =
+ directory.entryList({ QStringLiteral("*.md") }, QDir::Files, QDir::Name);
+ result.reserve(files.size());
+ for (const QString &file : files)
+ result.append(QFileInfo(file).completeBaseName());
+ return result;
+}
+
+QString text(const QString &dir, const QString &name)
+{
+ // A name arriving from the config file is untrusted input reaching a path.
+ // Stems from names() never contain a separator, so rejecting one costs
+ // nothing and stops a name like `../../.ssh/id_rsa` from being read into a
+ // message the user is about to send.
+ if (name.isEmpty() || name.contains(QLatin1Char('/'))
+ || name.contains(QLatin1Char('\\')))
+ return {};
+
+ QFile file(dir + QStringLiteral("/") + name + QStringLiteral(".md"));
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
+ return {};
+ return QString::fromUtf8(file.readAll());
+}
+
+namespace {
+
+/// The RFC 3676 signature separator: two hyphens, a space, end of line.
+///
+/// The trailing space is part of the standard and is what receiving clients
+/// match on to fold or strip a signature. It is also why `--` typed by hand
+/// does not collide: an editor does not add trailing whitespace on its own.
+const QLatin1String kDelimiter("-- ");
+
+bool isQuoted(const QString &line)
+{
+ return line.startsWith(QLatin1Char('>'));
+}
+
+/// \p text with trailing blank lines removed, the same normalisation the block
+/// scan below applies. text() returns file content verbatim, so a signature
+/// file ends with the newline every editor writes; without this the match
+/// compares a block with no trailing newline against a known entry that has
+/// one, and the guard silently fails, appending a second signature instead of
+/// replacing the first.
+QString stripTrailingBlankLines(const QString &text)
+{
+ QStringList lines = text.split(QLatin1Char('\n'));
+ while (!lines.isEmpty() && lines.last().trimmed().isEmpty())
+ lines.removeLast();
+ return lines.join(QLatin1Char('\n'));
+}
+
+/// The index of the first line of the quote, or -1 when the buffer has none.
+///
+/// The attribution line ("On Mon, someone wrote:") is deliberately NOT
+/// included: it introduces the quote and belongs with it, so a signature
+/// inserted above the quote goes above the attribution too. Returning the
+/// quoted line itself would strand the signature between the attribution and
+/// the text it introduces.
+int quoteStart(const QStringList &lines, int from = 0)
+{
+ for (int i = from; i < lines.size(); ++i) {
+ if (!isQuoted(lines.at(i)))
+ continue;
+ // Walk back over the attribution and the blank line before it, so the
+ // signature lands above the whole block rather than inside it.
+ int start = i;
+ while (start > from && !lines.at(start - 1).trimmed().isEmpty()
+ && !isQuoted(lines.at(start - 1)))
+ --start;
+ return start;
+ }
+ return -1;
+}
+
+/// Where the block introduced by the delimiter at \p delimiter ends: the start
+/// of the quote below it, or the end of the buffer when there is none.
+///
+/// This must use quoteStart() rather than scanning for the first quoted line,
+/// because the ATTRIBUTION is part of the quote. Scanning for `>` alone puts
+/// "On Mon, someone wrote:" inside the signature block, which then matches no
+/// known signature and, when it did, left the attribution stranded above the
+/// removed text. The two boundaries have to be the same one.
+int blockEnd(const QStringList &lines, int delimiter)
+{
+ const int quote = quoteStart(lines, delimiter + 1);
+ return quote < 0 ? lines.size() : quote;
+}
+
+/// The line index of the delimiter introducing an existing signature, or -1.
+///
+/// Two conditions, and both are load-bearing. The delimiter must not be
+/// QUOTED, since the quoted original carries the other party's signature and
+/// it is not this message's to replace. And the block after it must MATCH one
+/// of \p known: finding a delimiter is not authority to delete what follows
+/// it, because "-- " reaches a buffer pasted in with quoted text.
+int existingSignature(const QStringList &lines, const QStringList &known)
+{
+ for (int i = lines.size() - 1; i >= 0; --i) {
+ if (lines.at(i) != kDelimiter)
+ continue;
+
+ // The block runs to the end, or to the quote when the signature sits
+ // above one.
+ const int end = blockEnd(lines, i);
+ // A trailing blank line belongs to the separation, not to the text.
+ int textEnd = end;
+ while (textEnd > i + 1 && lines.at(textEnd - 1).trimmed().isEmpty())
+ --textEnd;
+
+ const QString block =
+ lines.mid(i + 1, textEnd - (i + 1)).join(QLatin1Char('\n'));
+ if (known.contains(block))
+ return i;
+ }
+ return -1;
+}
+
+/// \p lines with the signature at \p delimiter removed, blank separator and
+/// all. The caller has already established that the block is a known one.
+QStringList withoutSignature(const QStringList &lines, int delimiter)
+{
+ const int end = blockEnd(lines, delimiter);
+
+ QStringList head = lines.mid(0, delimiter);
+ while (!head.isEmpty() && head.last().trimmed().isEmpty())
+ head.removeLast();
+
+ QStringList result = head;
+ if (end < lines.size()) {
+ // Something follows (the quote): restore the blank line that
+ // separated it from the signature now being removed.
+ result.append(QString());
+ result.append(lines.mid(end));
+ } else {
+ // The signature ran to the end of the buffer, and the trailing
+ // newline the head lost with its blank line goes back.
+ result.append(QString());
+ }
+ return result;
+}
+
+} // namespace
+
+QString replace(const QString &buffer, const QString &signature,
+ const QStringList &known, Position position)
+{
+ // Normalise known to the same footing the block scan uses, once here rather
+ // than per comparison. knownSignatures() passes text() verbatim, trailing
+ // newline and all, and the match must be newline-insensitive or the guard
+ // treats every on-disk signature as unknown.
+ QStringList normalized;
+ normalized.reserve(known.size());
+ for (const QString &entry : known)
+ normalized.append(stripTrailingBlankLines(entry));
+
+ QStringList lines = buffer.split(QLatin1Char('\n'));
+
+ const int existing = existingSignature(lines, normalized);
+ if (existing >= 0)
+ lines = withoutSignature(lines, existing);
+
+ const QString stripped = lines.join(QLatin1Char('\n'));
+
+ // "None", or nothing to insert: the removal above is the whole operation.
+ if (signature.isEmpty())
+ return stripped;
+
+ const QString block = QStringLiteral("\n") + kDelimiter
+ + QStringLiteral("\n") + signature;
+
+ const int quote =
+ position == Position::AboveQuote ? quoteStart(lines) : -1;
+
+ // No quote to sit above is not a special case: it is the End placement,
+ // which is why a New message needs no branch of its own.
+ if (quote < 0)
+ return stripped + block;
+
+ QStringList head = lines.mid(0, quote);
+ const QStringList tail = lines.mid(quote);
+ // The head ends in however many blank lines separated the reply from the
+ // attribution. Drop them all and let the block supply exactly one, so the
+ // spacing is the same whatever the quote was seeded with.
+ while (!head.isEmpty() && head.last().trimmed().isEmpty())
+ head.removeLast();
+
+ // head.join() has no trailing newline once trimmed, so the terminator for
+ // its last line is supplied here; `block` then opens with the blank line,
+ // which is the same shape as the End placement over a buffer ending in a
+ // newline.
+ return head.join(QLatin1Char('\n')) + QStringLiteral("\n") + block
+ + QStringLiteral("\n\n") + tail.join(QLatin1Char('\n'));
+}
+
+} // namespace Signatures
diff --git a/src/signatures.h b/src/signatures.h
new file mode 100644
index 0000000..e2bfd9c
--- /dev/null
+++ b/src/signatures.h
@@ -0,0 +1,76 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QString>
+#include <QStringList>
+
+/// Signatures, as markdown files spliced into the composer's buffer.
+///
+/// Free functions over values, with no widget anywhere, matching
+/// MarkdownFormat, MessageBuilder and DraftStore. The splice is the part worth
+/// testing and it is testable with no painter.
+///
+/// MARKDOWN, and that is what makes this small: MessageBuilder already builds
+/// text/plain from the buffer verbatim and text/html from MarkdownRenderer
+/// over the same string, so a signature in the buffer yields both forms with
+/// no change there and no second code path. One choice by the user serves both
+/// parts, which is what the feature was asked for.
+namespace Signatures {
+
+/// Where a newly inserted signature goes, from [compose] signature_position.
+enum class Position {
+ End, ///< The end of the buffer. The default and the user's habit.
+ AboveQuote ///< Before the first quoted line, or the end when there is none.
+};
+
+/// The stems of every `*.md` in \p dir, sorted, without the extension.
+///
+/// A missing or unreadable directory yields an empty list. That is not a
+/// misconfiguration: it means the user keeps no signatures, and the switch
+/// then offers only "None".
+QStringList names(const QString &dir);
+
+/// The content of `<dir>/<name>.md`, or empty when it cannot be read.
+///
+/// \p name is a stem from names(), never a path. It is rejected if it contains
+/// a path separator, so a value arriving from the config file cannot reach
+/// outside \p dir.
+QString text(const QString &dir, const QString &name);
+
+/// Returns \p buffer with \p signature spliced in.
+///
+/// Any signature already present is replaced; \p signature empty removes it
+/// and inserts nothing, which is what "None" selects.
+///
+/// \p known is the text of every signature in the directory, and it is what
+/// makes this non-destructive. A `-- ` delimiter is NOT sufficient authority
+/// to delete what follows it: the block is replaced only when its text matches
+/// one of \p known, and otherwise the new signature is INSERTED with nothing
+/// removed. `-- ` reaches a buffer without the user ever choosing a signature,
+/// most plausibly pasted in with quoted text from another client, and the
+/// unguarded rule would silently delete everything after it.
+///
+/// The failure is therefore directional, which is the whole point: a wrong
+/// guess adds a visible second signature, one undo away, rather than losing
+/// the user's own writing.
+QString replace(const QString &buffer, const QString &signature,
+ const QStringList &known, Position position);
+
+} // namespace Signatures