diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-24 19:38:45 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-24 19:38:45 +0200 |
| commit | f8d136432466479c892841bc73bd85e58674da86 (patch) | |
| tree | 171f2ca73ac2f04cae85fac11e18dd88a6798cf5 /docs/superpowers | |
| parent | f00191bf8e0a20cb578aab08dfdf6a7de6c8f872 (diff) | |
| download | qtmaildir-f8d136432466479c892841bc73bd85e58674da86.tar.gz qtmaildir-f8d136432466479c892841bc73bd85e58674da86.zip | |
docs(plans): implementation plan for signatures, item 152
Eight tasks, TDD throughout, against
specs/2026-08-24-signatures-design.md.
Tasks 1 to 3 build the Signatures namespace: reading the directory, the
splice for both placements, and the match guard that keeps a "-- "
delimiter from authorising a deletion. Task 3 carries a mutation check on
that guard, since it is the one piece preventing data loss.
Task 4 adds the three config keys, task 5 the editor-bar switch and the
seeding, task 6 the From: follow that stops once the user chooses. Task 7
is documentation and the Italian translation; task 8 closes the backlog
item, and deliberately hands the work over for a hand test first rather
than marking it done on a green suite.
MessageBuilder is untouched by every task, which the plan states twice: the
signature lives in the composer's buffer and both MIME parts are already
derived from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-08-24-signatures.md | 1939 |
1 files changed, 1939 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-24-signatures.md b/docs/superpowers/plans/2026-08-24-signatures.md new file mode 100644 index 0000000..93026fc --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-signatures.md @@ -0,0 +1,1939 @@ +# Signatures Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the composer a signature the user picks from a control on the editor bar, stored as one markdown file per signature. + +**Architecture:** A new `Signatures` namespace of free functions reads a directory of `*.md` files and splices a signature into a plain-text buffer, finding any existing one by the RFC 3676 `-- ` delimiter and replacing it only when its text matches a known signature. `ComposeWindow` seeds from config on open and re-seeds on a From: change until the user touches the switch. `MessageBuilder` is not touched at all: it already derives `text/plain` verbatim and `text/html` through `MarkdownRenderer` from the same string, so a markdown signature in the buffer yields both forms. + +**Tech Stack:** Qt 6 (QDir, QFile, QToolButton, QMenu), C++17, QtTest. No new dependency. + +**Spec:** `docs/superpowers/specs/2026-08-24-signatures-design.md`. Backlog item 152. + +**Read before starting:** `CLAUDE.md`, in particular the rules on `tr()` and `QT_TRANSLATE_NOOP`, on never running a test binary without `QT_QPA_PLATFORM=offscreen`, and on never launching the application unasked. + +--- + +## File structure + +| File | Responsibility | +|---|---| +| `src/signatures.h` (create) | The `Signatures` namespace: `Position`, `names()`, `text()`, `replace()` | +| `src/signatures.cpp` (create) | Its implementation. No widget, no Qt GUI type | +| `src/config.h` (modify) | `Account::signature`; `ComposeSettings::signature`, `::signaturePosition` | +| `src/config.cpp` (modify) | Reads all three keys, reporting a malformed position | +| `src/composewindow.h` (modify) | The switch's members and the seeding helpers | +| `src/composewindow.cpp` (modify) | Builds the switch, seeds on open, follows From: until used | +| `tests/test_signatures.cpp` (create) | The namespace, no widget | +| `tests/test_composewindow.cpp` (create) | The composer wiring. No such file exists yet | +| `src/CMakeLists.txt` (modify) | `signatures.cpp` in `qtmaildir_lib` | +| `tests/CMakeLists.txt` (modify) | `add_qtmaildir_test(signatures)`, `add_qtmaildir_test(composewindow)` | +| `README.md` (modify) | Document the directory and the three keys | +| `CHANGELOG.md` (modify) | An `Added` entry under `[Unreleased]` | +| `translations/qtmaildir_it_IT.ts` (modify) | Refreshed with `lupdate-qt6`, translated, 0 unfinished | + +**Build and test commands** used throughout: + +```bash +cmake --build build +ctest --test-dir build --output-on-failure -R signatures +QT_QPA_PLATFORM=offscreen ./build/tests/test_signatures -functions +``` + +Reconfigure only when a CMakeLists changes: + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +``` + +--- + +## Task 1: The `Signatures` namespace, reading a directory + +**Files:** +- Create: `src/signatures.h`, `src/signatures.cpp` +- Modify: `src/CMakeLists.txt:1-39` +- Create: `tests/test_signatures.cpp` +- Modify: `tests/CMakeLists.txt` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_signatures.cpp`: + +```cpp +/* + * 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 <QtTest> +#include <QTemporaryDir> + +#include "signatures.h" + +class TestSignatures : public QObject +{ + Q_OBJECT + +private slots: + void namesAreTheFileStemsSorted(); + void namesIgnoreFilesThatAreNotMarkdown(); + void aMissingDirectoryHasNoNames(); + void textIsTheFileContent(); + void textOfAnUnknownNameIsEmpty(); + +private: + /// Writes \p files as name -> content into a fresh temporary directory. + static void write(const QTemporaryDir &dir, + const QList<QPair<QString, QString>> &files); +}; + +void TestSignatures::write(const QTemporaryDir &dir, + const QList<QPair<QString, QString>> &files) +{ + for (const auto &entry : files) { + QFile file(dir.path() + QStringLiteral("/") + entry.first); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(entry.second.toUtf8()); + file.close(); + } +} + +void TestSignatures::namesAreTheFileStemsSorted() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + write(dir, { { QStringLiteral("work.md"), QStringLiteral("Work") }, + { QStringLiteral("brief.md"), QStringLiteral("Brief") } }); + + QCOMPARE(Signatures::names(dir.path()), + QStringList({ QStringLiteral("brief"), QStringLiteral("work") })); +} + +void TestSignatures::namesIgnoreFilesThatAreNotMarkdown() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + write(dir, { { QStringLiteral("work.md"), QStringLiteral("Work") }, + { QStringLiteral("notes.txt"), QStringLiteral("Not one") }, + { QStringLiteral("README"), QStringLiteral("Nor this") } }); + + QCOMPARE(Signatures::names(dir.path()), + QStringList({ QStringLiteral("work") })); +} + +void TestSignatures::aMissingDirectoryHasNoNames() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString missing = dir.path() + QStringLiteral("/nothing-here"); + + QVERIFY(Signatures::names(missing).isEmpty()); +} + +void TestSignatures::textIsTheFileContent() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + write(dir, { { QStringLiteral("work.md"), + QStringLiteral("Danilo\n**qtmaildir**\n") } }); + + QCOMPARE(Signatures::text(dir.path(), QStringLiteral("work")), + QStringLiteral("Danilo\n**qtmaildir**\n")); +} + +void TestSignatures::textOfAnUnknownNameIsEmpty() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + QVERIFY(Signatures::text(dir.path(), QStringLiteral("absent")).isEmpty()); +} + +QTEST_MAIN(TestSignatures) +#include "test_signatures.moc" +``` + +- [ ] **Step 2: Register the test and the source, then run it to verify it fails** + +Add to `src/CMakeLists.txt`, in the `qtmaildir_lib` source list after `searchterm.cpp`: + +```cmake + signatures.cpp +``` + +Add to `tests/CMakeLists.txt`, after `add_qtmaildir_test(searchterm)`: + +```cmake +add_qtmaildir_test(signatures) +``` + +Run: + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug && cmake --build build +``` + +Expected: FAIL at configure or compile time, with `signatures.cpp` not found. + +- [ ] **Step 3: Write the header** + +Create `src/signatures.h`: + +```cpp +/* + * 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 +``` + +- [ ] **Step 4: Write the minimal implementation of `names()` and `text()`** + +Create `src/signatures.cpp`. `replace()` is a stub here and is built in Task 2: + +```cpp +/* + * 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 `signature = ../../.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()); +} + +QString replace(const QString &buffer, const QString &signature, + const QStringList &known, Position position) +{ + Q_UNUSED(signature); + Q_UNUSED(known); + Q_UNUSED(position); + return buffer; +} + +} // namespace Signatures +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R signatures +``` + +Expected: PASS, 5 of 5. + +- [ ] **Step 6: Commit** + +```bash +git add src/signatures.h src/signatures.cpp tests/test_signatures.cpp \ + src/CMakeLists.txt tests/CMakeLists.txt +git commit -S -m "feat(signatures): read a directory of markdown signatures + +One file per signature under a directory the caller names, the stem being +the name shown to the user. A name containing a path separator is refused: +it arrives from the config file, and it reaches a path that is read into a +message about to be sent. + +Part of item 152." +``` + +--- + +## Task 2: The splice, inserting into a buffer with no signature + +**Files:** +- Modify: `src/signatures.cpp` (the `replace()` stub) +- Modify: `tests/test_signatures.cpp` + +- [ ] **Step 1: Write the failing tests** + +Add to the `private slots:` block of `tests/test_signatures.cpp`: + +```cpp + void insertingAtTheEndAppendsAfterADelimiter(); + void insertingAboveTheQuotePutsItBeforeTheFirstQuotedLine(); + void insertingAboveTheQuoteWithNoQuoteIsTheSameAsEnd(); + void insertingNothingLeavesTheBufferAlone(); +``` + +Add the implementations before `QTEST_MAIN`: + +```cpp +void TestSignatures::insertingAtTheEndAppendsAfterADelimiter() +{ + const QString buffer = QStringLiteral("Hello.\n"); + + const QString result = Signatures::replace( + buffer, QStringLiteral("Danilo"), {}, Signatures::Position::End); + + QCOMPARE(result, QStringLiteral("Hello.\n\n-- \nDanilo")); +} + +void TestSignatures::insertingAboveTheQuotePutsItBeforeTheFirstQuotedLine() +{ + const QString buffer = QStringLiteral( + "My reply.\n" + "\n" + "On Mon, someone wrote:\n" + "> the original\n" + "> second line\n"); + + const QString result = Signatures::replace( + buffer, QStringLiteral("Danilo"), {}, + Signatures::Position::AboveQuote); + + // Before the QUOTED lines, and the attribution stays with the quote it + // introduces: it is the line the quote hangs from, not part of the reply. + QCOMPARE(result, QStringLiteral( + "My reply.\n" + "\n" + "-- \n" + "Danilo\n" + "\n" + "On Mon, someone wrote:\n" + "> the original\n" + "> second line\n")); +} + +void TestSignatures::insertingAboveTheQuoteWithNoQuoteIsTheSameAsEnd() +{ + const QString buffer = QStringLiteral("A new message.\n"); + + const QString above = Signatures::replace( + buffer, QStringLiteral("Danilo"), {}, + Signatures::Position::AboveQuote); + const QString end = Signatures::replace( + buffer, QStringLiteral("Danilo"), {}, Signatures::Position::End); + + QCOMPARE(above, end); +} + +void TestSignatures::insertingNothingLeavesTheBufferAlone() +{ + const QString buffer = QStringLiteral("Hello.\n"); + + QCOMPARE(Signatures::replace(buffer, QString(), {}, + Signatures::Position::End), + buffer); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R signatures +``` + +Expected: FAIL, 4 failures, each comparing the unchanged buffer against the expected splice (the stub returns `buffer`). + +- [ ] **Step 3: Implement the insertion** + +Replace the `replace()` stub in `src/signatures.cpp` with this, and add the helpers above it inside the namespace: + +```cpp +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('>')); +} + +/// 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) +{ + for (int i = 0; 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 > 0 && !lines.at(start - 1).trimmed().isEmpty() + && !isQuoted(lines.at(start - 1))) + --start; + return start; + } + return -1; +} + +} // namespace + +QString replace(const QString &buffer, const QString &signature, + const QStringList &known, Position position) +{ + Q_UNUSED(known); + + if (signature.isEmpty()) + return buffer; + + const QString block = QStringLiteral("\n") + kDelimiter + + QStringLiteral("\n") + signature; + + QStringList lines = buffer.split(QLatin1Char('\n')); + 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 buffer + block; + + QStringList head = lines.mid(0, quote); + const QStringList tail = lines.mid(quote); + // The head already ends in the blank line that separated the reply from + // the attribution, so the block's own leading newline would double it. + while (!head.isEmpty() && head.last().trimmed().isEmpty()) + head.removeLast(); + + return head.join(QLatin1Char('\n')) + block + QStringLiteral("\n\n") + + tail.join(QLatin1Char('\n')); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R signatures +``` + +Expected: PASS, 9 of 9. + +- [ ] **Step 5: Commit** + +```bash +git add src/signatures.cpp tests/test_signatures.cpp +git commit -S -m "feat(signatures): splice a signature into a buffer + +Both placements over one implementation. above_quote inserts before the +attribution rather than before the first quoted line: the attribution +introduces the quote and belongs with it, and a signature between the two +would read as part of the quoted message. + +A buffer with no quote makes above_quote identical to end, so a new +message needs no branch of its own. + +Part of item 152." +``` + +--- + +## Task 3: Replacing an existing signature, and the guard that makes it safe + +**Files:** +- Modify: `src/signatures.cpp` (`replace()`) +- Modify: `tests/test_signatures.cpp` + +This is the task that carries the spec's data-loss guard. The last test here must not be dropped. + +- [ ] **Step 1: Write the failing tests** + +Add to the `private slots:` block: + +```cpp + void switchingReplacesAKnownSignature(); + void switchingReplacesAKnownSignatureAboveAQuote(); + void selectingNoneRemovesAKnownSignature(); + void aBlockMatchingNoKnownSignatureIsNotRemoved(); + void aDelimiterInsideTheQuoteIsNotTheSignature(); +``` + +Add the implementations: + +```cpp +void TestSignatures::switchingReplacesAKnownSignature() +{ + const QStringList known = { QStringLiteral("Danilo"), + QStringLiteral("Danilo M.\nqtmaildir") }; + const QString buffer = QStringLiteral("Hello.\n\n-- \nDanilo"); + + const QString result = Signatures::replace( + buffer, QStringLiteral("Danilo M.\nqtmaildir"), known, + Signatures::Position::End); + + QCOMPARE(result, + QStringLiteral("Hello.\n\n-- \nDanilo M.\nqtmaildir")); +} + +void TestSignatures::switchingReplacesAKnownSignatureAboveAQuote() +{ + const QStringList known = { QStringLiteral("Danilo"), + QStringLiteral("Brief") }; + const QString buffer = QStringLiteral( + "My reply.\n" + "\n" + "-- \n" + "Danilo\n" + "\n" + "On Mon, someone wrote:\n" + "> the original\n"); + + const QString result = Signatures::replace( + buffer, QStringLiteral("Brief"), known, + Signatures::Position::AboveQuote); + + QCOMPARE(result, QStringLiteral( + "My reply.\n" + "\n" + "-- \n" + "Brief\n" + "\n" + "On Mon, someone wrote:\n" + "> the original\n")); +} + +void TestSignatures::selectingNoneRemovesAKnownSignature() +{ + const QStringList known = { QStringLiteral("Danilo") }; + const QString buffer = QStringLiteral("Hello.\n\n-- \nDanilo"); + + const QString result = Signatures::replace( + buffer, QString(), known, Signatures::Position::End); + + QCOMPARE(result, QStringLiteral("Hello.\n")); +} + +void TestSignatures::aBlockMatchingNoKnownSignatureIsNotRemoved() +{ + // THE test for the data-loss guard, and it must not be dropped. A "-- " + // reaches a buffer without the user ever choosing a signature, pasted in + // with quoted text from another client. Replacing from there would delete + // everything after it silently. + const QStringList known = { QStringLiteral("Danilo") }; + const QString buffer = QStringLiteral( + "Hello.\n" + "\n" + "-- \n" + "text the user pasted and wants to keep"); + + const QString result = Signatures::replace( + buffer, QStringLiteral("Danilo"), known, Signatures::Position::End); + + // The user's text survives, and the signature is ADDED. A wrong guess + // produces a visible duplicate, never a deletion. + QVERIFY(result.contains( + QStringLiteral("text the user pasted and wants to keep"))); + QVERIFY(result.endsWith(QStringLiteral("-- \nDanilo"))); +} + +void TestSignatures::aDelimiterInsideTheQuoteIsNotTheSignature() +{ + // The quoted original carries the sender's own signature, quoted. A tail + // rule would find it, and under End it would append after it; the block + // must not be treated as this message's signature whichever way it goes. + const QStringList known = { QStringLiteral("Danilo") }; + const QString buffer = QStringLiteral( + "My reply.\n" + "\n" + "On Mon, someone wrote:\n" + "> the original\n" + "> -- \n" + "> Their Name\n"); + + const QString result = Signatures::replace( + buffer, QStringLiteral("Danilo"), known, Signatures::Position::End); + + QVERIFY(result.contains(QStringLiteral("> -- \n> Their Name"))); + QVERIFY(result.endsWith(QStringLiteral("-- \nDanilo"))); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R signatures +``` + +Expected: FAIL. `switchingReplacesAKnownSignature` shows two signatures in the result, because Task 2's `replace()` only ever appends. + +- [ ] **Step 3: Implement the find-and-remove** + +Add this helper to the anonymous namespace in `src/signatures.cpp`, after `quoteStart()`: + +```cpp +/// 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. + int end = i + 1; + while (end < lines.size() && !isQuoted(lines.at(end))) + ++end; + // 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) +{ + int end = delimiter + 1; + while (end < lines.size() && !isQuoted(lines.at(end))) + ++end; + + 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; +} +``` + +Then change the body of `replace()` to remove before inserting. Replace the whole function with: + +```cpp +QString replace(const QString &buffer, const QString &signature, + const QStringList &known, Position position) +{ + QStringList lines = buffer.split(QLatin1Char('\n')); + + const int existing = existingSignature(lines, known); + 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; + + if (quote < 0) + return stripped + block; + + QStringList head = lines.mid(0, quote); + const QStringList tail = lines.mid(quote); + while (!head.isEmpty() && head.last().trimmed().isEmpty()) + head.removeLast(); + + return head.join(QLatin1Char('\n')) + block + QStringLiteral("\n\n") + + tail.join(QLatin1Char('\n')); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R signatures +``` + +Expected: PASS, 14 of 14. + +- [ ] **Step 5: Mutation-check the guard** + +The guard is the one thing here that prevents data loss, so prove its test can fail. Temporarily change `existingSignature()` to skip the match: + +```cpp + if (true) // was: if (known.contains(block)) + return i; +``` + +Run: + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R signatures +``` + +Expected: FAIL, and specifically `aBlockMatchingNoKnownSignatureIsNotRemoved` reporting that the pasted text is gone. **Revert the mutation** and re-run to confirm green before committing. + +- [ ] **Step 6: Commit** + +```bash +git add src/signatures.cpp tests/test_signatures.cpp +git commit -S -m "feat(signatures): replace an existing signature, guarded by a match + +Finding a \"-- \" delimiter is not authority to delete what follows it. The +block is replaced only when its text matches one of the signatures on disk, +and otherwise the new one is inserted with nothing removed, so a wrong guess +adds a visible duplicate rather than destroying the user's writing. + +A quoted delimiter is never the signature either: the quoted original +carries the other party's, and it is not this message's to replace. + +The guard's test was mutation-checked by making the match unconditional, +which fails it. + +Part of item 152." +``` + +--- + +## Task 4: The three config keys + +**Files:** +- Modify: `src/config.h:31-60` (`Account`), `src/config.h:202-232` (`ComposeSettings`) +- Modify: `src/config.cpp:443-500` (the accounts loop), `src/config.cpp:528-548` (the `[compose]` group) +- Modify: `tests/test_config.cpp` + +- [ ] **Step 1: Write the failing tests** + +Add to the `private slots:` block of `tests/test_config.cpp`: + +```cpp + void theSignatureKeysAreRead(); + void anAccountSignatureOverridesTheComposeDefault(); + void aMalformedSignaturePositionIsReportedAndFallsBack(); +``` + +Add the implementations before `QTEST_MAIN`. Follow the file's existing helper for writing a config; the pattern below matches the other tests in it: + +```cpp +void TestConfig::theSignatureKeysAreRead() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.path() + QStringLiteral("/qtmaildir.conf"); + { + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << "[compose]\n" + << "signature = work\n" + << "signature_position = above_quote\n"; + } + + Config config; + config.load(path); + + QCOMPARE(config.compose().signature, QStringLiteral("work")); + QCOMPARE(config.compose().signaturePosition, + Signatures::Position::AboveQuote); +} + +void TestConfig::anAccountSignatureOverridesTheComposeDefault() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.path() + QStringLiteral("/qtmaildir.conf"); + { + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << "[compose]\n" + << "signature = work\n" + << "\n" + << "[account.personal]\n" + << "name = Someone\n" + << "address = someone@example.org\n" + << "maildir = personal\n" + << "signature = brief\n" + << "\n" + << "[account.other]\n" + << "name = Someone\n" + << "address = other@example.org\n" + << "maildir = other\n"; + } + + Config config; + config.load(path); + + // The account SEEDS the choice; it does not own the signature. The key is + // a starting value and the switch keeps every signature reachable. + QCOMPARE(config.account(QStringLiteral("personal")).signature, + QStringLiteral("brief")); + // An account with no key of its own carries none, and the caller falls + // through to the [compose] default rather than this being resolved here. + QVERIFY(config.account(QStringLiteral("other")).signature.isEmpty()); + QCOMPARE(config.compose().signature, QStringLiteral("work")); +} + +void TestConfig::aMalformedSignaturePositionIsReportedAndFallsBack() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.path() + QStringLiteral("/qtmaildir.conf"); + { + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << "[compose]\n" + << "signature_position = abov\n"; + } + + Config config; + config.load(path); + + // Present and malformed is REPORTED, matching quote_position. A silent + // value(key, default) would accept "abov" as above_quote. + QCOMPARE(config.compose().signaturePosition, Signatures::Position::End); + bool reported = false; + for (const QString &problem : config.problems()) { + if (problem.contains(QStringLiteral("signature_position"))) + reported = true; + } + QVERIFY(reported); +} +``` + +Add the include at the top of `tests/test_config.cpp`, beside the existing ones: + +```cpp +#include "signatures.h" +``` + +- [ ] **Step 2: Run them to verify they fail** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R config +``` + +Expected: FAIL at compile time, `'struct ComposeSettings' has no member named 'signature'`. + +- [ ] **Step 3: Add the fields** + +In `src/config.h`, add the include beside the others at the top: + +```cpp +#include "signatures.h" +``` + +In `struct Account`, after the `sent` member: + +```cpp + /// 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). + QString signature; +``` + +In `struct ComposeSettings`, after `defaultAccount`: + +```cpp + /// 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; +``` + +- [ ] **Step 4: Read the keys** + +In `src/config.cpp`, inside the accounts loop after the `sent` key is read: + +```cpp + // Trimmed for the same reason every other name-ish key is: 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(); +``` + +In the `[compose]` group, after `send_html` is read: + +```cpp + 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)); + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R config +``` + +Expected: PASS, the whole `config` suite including the three new cases. + +- [ ] **Step 6: Commit** + +```bash +git add src/config.h src/config.cpp tests/test_config.cpp +git commit -S -m "feat(config): read the three signature keys + +[compose] signature and signature_position, and a per-account signature +that OVERRIDES the former. The account seeds the choice rather than owning +it: the composer's switch keeps every signature reachable whichever account +is selected, which is what keeps the note's \"not tied to an account\" +constraint intact. + +signature_position follows quote_position's shape exactly, reporting a +present-but-malformed value rather than accepting it silently. + +Part of item 152." +``` + +--- + +## Task 5: The switch on the editor bar, and seeding + +**Files:** +- Modify: `src/composewindow.h:190-240` +- Modify: `src/composewindow.cpp:127-147` (the constructor), `:409-540` (`buildFormatToolbar`), `:625-666` (`seedBody`), `:893-918` (`setSendControlsEnabled`) +- Create: `tests/test_composewindow.cpp` +- Modify: `tests/CMakeLists.txt` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_composewindow.cpp` with the GPL header used by every other file in `tests/`, then: + +```cpp +#include <QtTest> +#include <QTemporaryDir> +#include <QToolButton> +#include <QPlainTextEdit> +#include <QMenu> + +#include "composewindow.h" +#include "composecontext.h" +#include "config.h" +#include "signatures.h" + +class TestComposeWindow : public QObject +{ + Q_OBJECT + +private slots: + void init(); + void cleanup(); + + void aNewMessageSeedsTheComposeSignature(); + void anAccountSignatureOverridesTheComposeOne(); + void aResumedDraftSeedsNoSignature(); + void anUnknownSignatureNameSeedsNothing(); + void theSwitchListsEveryFileAndNone(); + +private: + /// A config pointing at a signatures directory holding \p files, with one + /// account that can send. + Config makeConfig(const QList<QPair<QString, QString>> &files, + const QString &composeSignature, + const QString &accountSignature = {}); + + QTemporaryDir *m_dir = nullptr; + QString m_signatureDir; +}; + +void TestComposeWindow::init() +{ + m_dir = new QTemporaryDir; + QVERIFY(m_dir->isValid()); + m_signatureDir = m_dir->path() + QStringLiteral("/signatures"); + QVERIFY(QDir().mkpath(m_signatureDir)); +} + +void TestComposeWindow::cleanup() +{ + delete m_dir; + m_dir = nullptr; +} + +Config TestComposeWindow::makeConfig( + const QList<QPair<QString, QString>> &files, + const QString &composeSignature, const QString &accountSignature) +{ + for (const auto &entry : files) { + QFile file(m_signatureDir + QStringLiteral("/") + entry.first); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(entry.second.toUtf8()); + file.close(); + } + + const QString path = m_dir->path() + QStringLiteral("/qtmaildir.conf"); + { + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << "[compose]\n" + << "signature = " << composeSignature << "\n" + << "\n" + << "[account.work]\n" + << "name = Someone\n" + << "address = someone@example.org\n" + << "maildir = work\n" + << "send_command = /bin/cat\n"; + if (!accountSignature.isEmpty()) + out << "signature = " << accountSignature << "\n"; + } + + Config config; + config.load(path); + return config; +} + +void TestComposeWindow::aNewMessageSeedsTheComposeSignature() +{ + const Config config = makeConfig( + { { QStringLiteral("work.md"), QStringLiteral("Danilo") } }, + QStringLiteral("work")); + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = QStringLiteral("work"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + QVERIFY(body->toPlainText().endsWith(QStringLiteral("-- \nDanilo"))); +} + +void TestComposeWindow::anAccountSignatureOverridesTheComposeOne() +{ + const Config config = makeConfig( + { { QStringLiteral("work.md"), QStringLiteral("Long one") }, + { QStringLiteral("brief.md"), QStringLiteral("Brief") } }, + QStringLiteral("work"), QStringLiteral("brief")); + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = QStringLiteral("work"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + QVERIFY(body->toPlainText().endsWith(QStringLiteral("-- \nBrief"))); +} + +void TestComposeWindow::aResumedDraftSeedsNoSignature() +{ + const Config config = makeConfig( + { { QStringLiteral("work.md"), QStringLiteral("Danilo") } }, + QStringLiteral("work")); + + // The saved body already carries whatever signature it was written with. + // Seeding again would put a SECOND one on a message written once. + ComposeContext context; + context.kind = ComposeContext::Kind::Draft; + context.accountKey = QStringLiteral("work"); + context.body = QStringLiteral("Half a thought.\n\n-- \nDanilo"); + context.draftPath = m_dir->path() + QStringLiteral("/draft"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + QCOMPARE(body->toPlainText().count(QStringLiteral("-- \nDanilo")), 1); +} + +void TestComposeWindow::anUnknownSignatureNameSeedsNothing() +{ + const Config config = makeConfig( + { { QStringLiteral("work.md"), QStringLiteral("Danilo") } }, + QStringLiteral("absent")); + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = QStringLiteral("work"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + // No signature, and the composer still opened rather than refusing. + QVERIFY(!body->toPlainText().contains(QStringLiteral("-- "))); +} + +void TestComposeWindow::theSwitchListsEveryFileAndNone() +{ + const Config config = makeConfig( + { { QStringLiteral("work.md"), QStringLiteral("Danilo") }, + { QStringLiteral("brief.md"), QStringLiteral("Brief") } }, + QString()); + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = QStringLiteral("work"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *button = + window.findChild<QToolButton *>(QStringLiteral("signatureSwitch")); + QVERIFY(button); + QVERIFY(button->menu()); + // "None" plus one per file. + QCOMPARE(button->menu()->actions().size(), 3); +} + +QTEST_MAIN(TestComposeWindow) +#include "test_composewindow.moc" +``` + +Register it in `tests/CMakeLists.txt`, after `add_qtmaildir_test(composecontext)`: + +```cmake +add_qtmaildir_test(composewindow) +``` + +- [ ] **Step 2: Run them to verify they fail** + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug && cmake --build build +``` + +Expected: FAIL at compile time, `'class ComposeWindow' has no member named 'setSignatureDir'`. + +- [ ] **Step 3: Add the members and the seam** + +In `src/composewindow.h`, in the public section after `lastSaveFailed()`: + +```cpp + /// 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(); +``` + +In the private section, beside `m_sendHtml`: + +```cpp + 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; +``` + +Add a private helper declaration beside `seedBody()`: + +```cpp + /// 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; +``` + +- [ ] **Step 4: Implement them** + +In `src/composewindow.cpp`, add the includes at the top beside the others: + +```cpp +#include <QMenu> +#include <QStandardPaths> + +#include "signatures.h" +``` + +Add the switch to `buildFormatToolbar()`, immediately after the `m_sendHtml` block and before the `m_sendAction` comment: + +```cpp + // 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); +``` + +Add the new methods, after `seedBody()`: + +```cpp +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 account SEEDS, it does not bind: this is a starting value, and the + // switch keeps every signature reachable whichever account is selected. + const Account account = m_config.account(m_context.accountKey); + 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); + 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(); +} +``` + +Call it from the constructor, after `seedBody()` on line 130: + +```cpp + seedSignature(); +``` + +Add the switch to `setSendControlsEnabled()`, beside `m_sendHtml`: + +```cpp + m_signatureSwitch->setEnabled(enabled); +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R composewindow +``` + +Expected: PASS, 5 of 5. + +- [ ] **Step 6: Commit** + +```bash +git add src/composewindow.h src/composewindow.cpp tests/test_composewindow.cpp \ + tests/CMakeLists.txt +git commit -S -m "feat(compose): a signature switch on the editor bar + +A QToolButton with a checkable menu at the right end of the editor bar, +where item 142 put the controls of the editor. Not registered in KeyMap: +parented to the composer like the formatting actions, so its scope is this +window. + +The signature is applied through a QTextCursor rather than setPlainText(), +which destroys the undo stack, and the seeded one is cleared from that +stack for the reason the seeded quote already is: one Ctrl+Z must not wipe +content the user never typed. + +A resumed draft seeds nothing. Its body already carries the signature it +was written with, and seeding again would put a second one on a message +written once. + +Part of item 152." +``` + +--- + +## Task 6: Following the From: account until the switch is used + +**Files:** +- Modify: `src/composewindow.cpp` (the `m_from` connection at `:406-408`) +- Modify: `tests/test_composewindow.cpp` + +- [ ] **Step 1: Write the failing tests** + +Add to the `private slots:` block of `tests/test_composewindow.cpp`: + +```cpp + void changingTheAccountFollowsItsSignature(); + void changingTheAccountStopsFollowingOnceTheSwitchIsUsed(); +``` + +Add the implementations. Note the config here needs TWO sending accounts with different signatures, so a wrong answer is distinguishable from a right one: + +```cpp +void TestComposeWindow::changingTheAccountFollowsItsSignature() +{ + for (const auto &entry : + QList<QPair<QString, QString>>{ + { QStringLiteral("work.md"), QStringLiteral("Work sig") }, + { QStringLiteral("home.md"), QStringLiteral("Home sig") } }) { + QFile file(m_signatureDir + QStringLiteral("/") + entry.first); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(entry.second.toUtf8()); + file.close(); + } + + const QString path = m_dir->path() + QStringLiteral("/qtmaildir.conf"); + { + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << "[account.work]\n" + << "name = Someone\naddress = someone@example.org\n" + << "maildir = work\nsend_command = /bin/cat\n" + << "signature = work\n" + << "\n[account.home]\n" + << "name = Someone\naddress = other@example.org\n" + << "maildir = home\nsend_command = /bin/cat\n" + << "signature = home\n"; + } + Config config; + config.load(path); + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = QStringLiteral("work"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *from = window.findChild<QComboBox *>(QStringLiteral("from")); + QVERIFY(body); + QVERIFY(from); + QVERIFY(body->toPlainText().contains(QStringLiteral("Work sig"))); + + // Select the other account by its key, never by index: the order of the + // combo is the config's and an index assertion would pass on the wrong one. + const int home = from->findData(QStringLiteral("home")); + QVERIFY(home >= 0); + from->setCurrentIndex(home); + + QVERIFY(body->toPlainText().contains(QStringLiteral("Home sig"))); + QVERIFY(!body->toPlainText().contains(QStringLiteral("Work sig"))); +} + +void TestComposeWindow::changingTheAccountStopsFollowingOnceTheSwitchIsUsed() +{ + for (const auto &entry : + QList<QPair<QString, QString>>{ + { QStringLiteral("work.md"), QStringLiteral("Work sig") }, + { QStringLiteral("home.md"), QStringLiteral("Home sig") }, + { QStringLiteral("chosen.md"), QStringLiteral("Chosen sig") } }) { + QFile file(m_signatureDir + QStringLiteral("/") + entry.first); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(entry.second.toUtf8()); + file.close(); + } + + const QString path = m_dir->path() + QStringLiteral("/qtmaildir.conf"); + { + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << "[account.work]\n" + << "name = Someone\naddress = someone@example.org\n" + << "maildir = work\nsend_command = /bin/cat\n" + << "signature = work\n" + << "\n[account.home]\n" + << "name = Someone\naddress = other@example.org\n" + << "maildir = home\nsend_command = /bin/cat\n" + << "signature = home\n"; + } + Config config; + config.load(path); + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = QStringLiteral("work"); + + ComposeWindow window(context, config, m_dir->path()); + window.setSignatureDir(m_signatureDir); + window.seedSignature(); + + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *from = window.findChild<QComboBox *>(QStringLiteral("from")); + auto *button = + window.findChild<QToolButton *>(QStringLiteral("signatureSwitch")); + QVERIFY(body); + QVERIFY(from); + QVERIFY(button); + + // The user picks one deliberately. + for (QAction *action : button->menu()->actions()) { + if (action->data().toString() == QStringLiteral("chosen")) + action->trigger(); + } + QVERIFY(body->toPlainText().contains(QStringLiteral("Chosen sig"))); + + const int home = from->findData(QStringLiteral("home")); + QVERIFY(home >= 0); + from->setCurrentIndex(home); + + // The deliberate choice survives the account change. Overwriting it is + // the one behaviour that can silently discard something the user just did. + QVERIFY(body->toPlainText().contains(QStringLiteral("Chosen sig"))); + QVERIFY(!body->toPlainText().contains(QStringLiteral("Home sig"))); +} +``` + +Add the include at the top of the test file: + +```cpp +#include <QComboBox> +``` + +- [ ] **Step 2: Run them to verify they fail** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R composewindow +``` + +Expected: FAIL. `changingTheAccountFollowsItsSignature` still shows "Work sig" after the account change, because nothing re-seeds. + +- [ ] **Step 3: Confirm the From: combo is already addressable (no change expected)** + +The tests find the combo by `objectName` and its accounts by `findData`. Both +already hold on master and this step is a check, not work: +`composewindow.cpp:259` sets `setObjectName(QStringLiteral("from"))` and +`:582` adds each item as `addItem(label, account.key)`. + +```bash +grep -n 'm_from->setObjectName\|m_from->addItem' src/composewindow.cpp +``` + +Expected: two hits, matching the lines above. If either is missing the tests +cannot address the combo, and it is added before continuing. + +- [ ] **Step 4: Re-seed on an account change** + +In `src/composewindow.cpp`, replace the existing `m_from` connection: + +```cpp + connect(m_from, &QComboBox::currentIndexChanged, this, + &ComposeWindow::markDirty); +``` + +with: + +```cpp + 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); + }); +``` + +`seededSignatureName()` reads `m_context.accountKey`, which does not follow the combo, so update it to read the combo instead. Replace its body with: + +```cpp +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; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R composewindow +``` + +Expected: PASS, 7 of 7. + +- [ ] **Step 6: Run the whole suite** + +```bash +ctest --test-dir build --output-on-failure +``` + +Expected: everything passes except `undoMovesTheMessageBack`, which is backlog item 136 and fails on master already. Confirm that is the only failure, and that its message is item 136's, not something this work caused. + +- [ ] **Step 7: Commit** + +```bash +git add src/composewindow.cpp tests/test_composewindow.cpp +git commit -S -m "feat(compose): the signature follows the account until it is chosen + +A From: change re-seeds the signature from the newly selected account, and +stops doing so the moment the user picks one from the switch. Re-seeding +unconditionally is the one behaviour that can silently discard a deliberate +choice made a moment earlier; this is the shape send_html already uses. + +seededSignatureName() reads the combo rather than the context, which +records where the composer opened and does not follow a change to it. + +Part of item 152." +``` + +--- + +## Task 7: Documentation, changelog and translation + +**Files:** +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Modify: `translations/qtmaildir_it_IT.ts` + +- [ ] **Step 1: Document the feature in the README** + +Find the Composing section (added by item 122) and add, after the existing `[compose]` key table: + +```markdown +### Signatures + +Signatures are markdown files, one per signature, in +`~/.config/qtmaildir/signatures/`: + +``` +~/.config/qtmaildir/signatures/ +├── work.md +├── personal.md +└── brief.md +``` + +The filename without its extension is the name shown in the composer. There +is no editor for them inside qtmaildir: the directory is yours to manage with +your own editor. + +Markdown, like the body of a message. `qtmaildir` builds the plain-text part +of a message from what you typed and the HTML part by rendering the same +text, so one signature serves both and you never choose a format. + +The composer's editor bar carries a **Signature** control listing every file +plus *None*. Choosing one replaces whichever is already in the message, so +switching is safe to do repeatedly. + +| Key | Default | Meaning | +|---|---|---| +| `[compose] signature` | none | The signature seeded on a new message, by name | +| `[compose] signature_position` | `end` | `end`, or `above_quote` to put it before the quoted text in a reply | +| `[account.<key>] signature` | none | Seeds this account's messages instead of the `[compose]` value | + +A signature is not tied to an account. `[account.<key>] signature` only +chooses which one a message *starts* with; every signature stays reachable +from the control whichever account you are sending from, and changing the +From: account re-seeds it only until you pick one yourself. +``` + +- [ ] **Step 2: Add the changelog entry** + +Under `## [Unreleased]`, in the `### Added` section (create it if the section is not there): + +```markdown +- Signatures. Markdown files in `~/.config/qtmaildir/signatures/`, one per + signature, chosen from a control on the composer's editor bar. One + signature serves both the plain-text and the HTML part of a message, since + the HTML is rendered from the same markdown. `[compose] signature` seeds a + new message, `[account.<key>] signature` overrides it per account, and + `[compose] signature_position` puts a new one at the end of the message + (the default) or above the quoted text in a reply. +``` + +- [ ] **Step 3: Refresh the translation** + +```bash +lupdate-qt6 src/ -ts translations/qtmaildir_it_IT.ts -no-obsolete -locations none +``` + +Expected: zero context warnings. If `lupdate` prints "tr() cannot be called without context", a literal needs `QT_TRANSLATE_NOOP("TheClass", "Text")` — see `CLAUDE.md`. + +- [ ] **Step 4: Translate the new strings** + +The new strings are `Signature`, `None`, `Chooses the signature added to this message.` and the `signature_position` warning. Translate each in the `.ts` and remove its `type="unfinished"`: + +```xml +<message> + <source>Signature</source> + <translation>Firma</translation> +</message> +<message> + <source>None</source> + <translation>Nessuna</translation> +</message> +<message> + <source>Chooses the signature added to this message.</source> + <translation>Sceglie la firma aggiunta a questo messaggio.</translation> +</message> +<message> + <source>[compose] signature_position '%1' is not recognised; expected end or above_quote. Using end.</source> + <translation>[compose] signature_position '%1' non è riconosciuto; atteso end o above_quote. Uso end.</translation> +</message> +``` + +- [ ] **Step 5: Verify the translation is complete** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure -R translations +``` + +Expected: PASS. `lrelease` must report **0 unfinished**: it silently drops an unfinished string and ships it as English inside an otherwise Italian UI. + +- [ ] **Step 6: Commit** + +```bash +git add README.md CHANGELOG.md translations/qtmaildir_it_IT.ts +git commit -S -m "docs(signatures): document the directory and the three keys + +Part of item 152." +``` + +--- + +## Task 8: Close the backlog item + +**Files:** +- Modify: `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md` (the item 152 row) +- Move: item 152's section to `docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md` + +- [ ] **Step 1: Hand the work to the user for a hand test** + +Do NOT mark the item done first. The rule in `CLAUDE.md` and in memory is that a green suite is not evidence the design is right, and this feature is judged by looking at it: where the control sits on the bar, whether the seeded signature reads correctly under the quote, whether switching feels safe. + +Tell the user what to look at: + +- the **Signature** control at the right of the editor bar, with Attach and Send as HTML +- a new message seeded with the `[compose] signature` +- switching between two signatures repeatedly, confirming nothing accumulates +- a reply under `signature_position = above_quote` +- a resumed draft, confirming it does not gain a second signature + +- [ ] **Step 2: After the user reports green, update the status table** + +Change item 152's row from `**specified**` to `**done** 2026-08-24, unreleased`, keeping the existing prose about why the design is what it is, and noting anything the hand test found. + +- [ ] **Step 3: Move the section to the closed file** + +Item 152's `## 152.` section moves from the open backlog to +`2026-08-03-post-0.1.0-usability-closed.md`, on the same commit. `CLAUDE.md` +requires this rather than leaving it for a later cleanup: that is how the file +reached five thousand lines the first time. + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md \ + docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md +git commit -S -m "docs(backlog): close item 152, signatures" +``` + +--- + +## Notes for the implementer + +**Never run a test binary without `QT_QPA_PLATFORM=offscreen`, and never launch +`./build/src/qtmaildir`.** `tests/CMakeLists.txt` sets the variable for ctest +only, so a binary invoked directly inherits the desktop's setting and throws +real windows onto the user's screen. Running the application is a hand test and +belongs to the user. This is in `CLAUDE.md` and the user has asked for it +specifically. + +**`MessageBuilder` is not modified by any task in this plan.** If a task seems +to need it, the design has been misread: the signature lives in the composer's +buffer, and both MIME parts are already derived from that buffer. + +**The guard in Task 3 is the one piece that prevents data loss.** Its test is +marked as not-to-be-dropped and is mutation-checked in that task. Do not +weaken `existingSignature()` to make an unrelated test pass. + +**Two tests need two accounts with different signatures**, in Task 6. One +account, or two with the same signature, answers identically whichever way the +code resolves it, which is the trap `CLAUDE.md` records for the item 87 fix +that was mutation-checked and green while corrupting real mail. |
