diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-25 09:13:21 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-25 09:13:21 +0200 |
| commit | 3e196bbaf6a5f3ebf6597e0ada1f559d24627ca6 (patch) | |
| tree | 5d76949bf34ab581826f0f699d60e0fc45b3e175 /src | |
| parent | f8d136432466479c892841bc73bd85e58674da86 (diff) | |
| parent | 0a26961f9a7ae6ab98051e182b92e64165758cf1 (diff) | |
| download | qtmaildir-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.txt | 1 | ||||
| -rw-r--r-- | src/composewindow.cpp | 168 | ||||
| -rw-r--r-- | src/composewindow.h | 44 | ||||
| -rw-r--r-- | src/config.cpp | 73 | ||||
| -rw-r--r-- | src/config.h | 23 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 11 | ||||
| -rw-r--r-- | src/notmuchworker.cpp | 93 | ||||
| -rw-r--r-- | src/notmuchworker.h | 22 | ||||
| -rw-r--r-- | src/signatures.cpp | 229 | ||||
| -rw-r--r-- | src/signatures.h | 76 |
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 |
