diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-23 21:15:13 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-23 21:15:13 +0200 |
| commit | fabcf080652c6e5d57bf234be5e100769a9b965b (patch) | |
| tree | 0de4222c1e2aab58c38d34c9e0e3c37c68298cc8 /src | |
| parent | c50bea78e036518ce1a2a3eb899bbb5e305affea (diff) | |
| parent | ddcae8d02ef46db522b3cf6c228196c7a66a6432 (diff) | |
| download | qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.tar.gz qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.zip | |
Merge branch 'compose-and-send': composing and sending mail
Item 123, built over 2026-08-20 to 2026-08-23 in thirteen tasks against
docs/superpowers/specs/2026-08-20-compose-and-send-design.md.
The application writes mail now. A composer window per message, markdown as
the body, drafts autosaving into the account's Maildir, and sending through a
per-account command on stdin rather than any network protocol of this
program's own. A countdown with an Undo stands between pressing Send and the
command running.
Two things came in alongside it. The notmuch auto-tagging hooks moved here
from the retiring `mailctl` project and learned that mail this application
files itself never arrived, so sent mail and drafts stop appearing in the
inbox. And the v1/v2 language is retired: semver on the user-visible surface
is the rule, and those labels described a split that composing made obsolete.
Hand tested against a fake send command rather than a real one, deliberately:
New, Reply and Forward all produce correct messages, a forwarded attachment
survives intact, and the sent copy is filed. That testing found the two
defects fixed on this branch, and both were invisible to the suite: a composer
orphaned by quitting the main window, and every sent message tagged `inbox`.
Twenty-two defects were found in the plan document's own draft code while
building it, which is why CLAUDE.md says to treat every code block in a plan
as a draft.
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 12 | ||||
| -rw-r--r-- | src/composecontext.cpp | 517 | ||||
| -rw-r--r-- | src/composecontext.h | 177 | ||||
| -rw-r--r-- | src/composewindow.cpp | 919 | ||||
| -rw-r--r-- | src/composewindow.h | 267 | ||||
| -rw-r--r-- | src/config.cpp | 160 | ||||
| -rw-r--r-- | src/config.h | 56 | ||||
| -rw-r--r-- | src/draftstore.cpp | 82 | ||||
| -rw-r--r-- | src/draftstore.h | 60 | ||||
| -rw-r--r-- | src/formattoolbar.cpp | 182 | ||||
| -rw-r--r-- | src/formattoolbar.h | 76 | ||||
| -rw-r--r-- | src/keymap.cpp | 33 | ||||
| -rw-r--r-- | src/maildirname.cpp | 80 | ||||
| -rw-r--r-- | src/maildirname.h | 41 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 719 | ||||
| -rw-r--r-- | src/mainwindow.h | 193 | ||||
| -rw-r--r-- | src/markdownrenderer.cpp | 110 | ||||
| -rw-r--r-- | src/markdownrenderer.h | 40 | ||||
| -rw-r--r-- | src/messagebuilder.cpp | 407 | ||||
| -rw-r--r-- | src/messagebuilder.h | 59 | ||||
| -rw-r--r-- | src/messagesender.cpp | 197 | ||||
| -rw-r--r-- | src/messagesender.h | 164 | ||||
| -rw-r--r-- | src/messageview.cpp | 29 | ||||
| -rw-r--r-- | src/messageview.h | 10 | ||||
| -rw-r--r-- | src/mimeparser.cpp | 2 | ||||
| -rw-r--r-- | src/mimeparser.h | 16 | ||||
| -rw-r--r-- | src/notmuchworker.cpp | 96 | ||||
| -rw-r--r-- | src/notmuchworker.h | 20 | ||||
| -rw-r--r-- | src/senddialog.cpp | 318 | ||||
| -rw-r--r-- | src/senddialog.h | 145 | ||||
| -rw-r--r-- | src/types.h | 41 |
31 files changed, 5165 insertions, 63 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b63ff3e..2cebfef 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,6 +2,8 @@ add_library(qtmaildir_lib STATIC keymap.cpp config.cpp mimeparser.cpp + markdownrenderer.cpp + messagebuilder.cpp requestinterceptor.cpp htmlbuilder.cpp cidschemehandler.cpp @@ -10,8 +12,15 @@ add_library(qtmaildir_lib STATIC marks.cpp carddelegate.cpp notmuchworker.cpp + maildirname.cpp + draftstore.cpp + messagesender.cpp + composecontext.cpp + formattoolbar.cpp tagchip.cpp tagcolors.cpp + senddialog.cpp + composewindow.cpp savequerydialog.cpp tagdialog.cpp tagrules.cpp @@ -36,7 +45,8 @@ target_include_directories(qtmaildir_lib target_link_libraries(qtmaildir_lib PUBLIC Qt6::Widgets Qt6::Svg Qt6::WebEngineWidgets PkgConfig::GMIME - ${NOTMUCH_LIBRARY}) + ${NOTMUCH_LIBRARY} PkgConfig::CMARK_GFM + ${CMARK_GFM_EXTENSIONS_LIBRARY}) # resources.qrc belongs to the executable, not to the static library. A qrc # compiled into a .a registers itself from a global initialiser, and the linker diff --git a/src/composecontext.cpp b/src/composecontext.cpp new file mode 100644 index 0000000..251a028 --- /dev/null +++ b/src/composecontext.cpp @@ -0,0 +1,517 @@ +/* + * 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. + */ + +// gmime BEFORE any Qt header. glib declares a struct field named "signals", +// which Qt #defines to Q_SIGNALS, and the collision is a compile error whose +// message names neither library. +#include <gmime/gmime.h> + +#include "composecontext.h" + +#include "config.h" +#include "mimeparser.h" + +#include <QDir> +#include <QSet> +#include <QRegularExpression> + +namespace { + +/// GMime must be initialised exactly once per process. +/// +/// MimeParser and MessageBuilder each carry their own copy of this guard, and +/// this is a third rather than a shared one for the reason MessageBuilder +/// already records: a test may link only one of them, so neither can assume +/// another ran. Without it the first internet_address_list_parse() call +/// dereferences an uninitialised type registry and SEGVs, which is exactly what +/// this file did before the guard was added. +/// +/// A function-local static rather than the `static bool` flag the other two +/// use: C++11 guarantees the initialiser runs exactly once even under +/// concurrent entry, which a bare flag does not. +void ensureGMimeInitialised() +{ + static const bool initialised = [] { + g_mime_init(); + return true; + }(); + Q_UNUSED(initialised); +} + +/// Matches a reply prefix at the START of a subject, in the spellings clients +/// actually produce. +/// +/// Anchored, and that is load-bearing rather than tidy: an unanchored search +/// finds "re:" inside an ordinary subject ("Notes re: budget") and refuses to +/// prefix a genuine first reply, which breaks threading in the recipient's +/// client with nothing to see locally. +/// +/// **The non-English spellings are not politeness, they are the doubling bug in +/// a mixed-locale mailbox**, which this one is: the user writes Italian and +/// corresponds beyond it. German and Dutch clients send `AW:`, Scandinavian +/// ones `SV:`, Spanish and Portuguese `RES:`. An English-only pattern turns +/// every one of those into `Re: AW: subject`, and the next round into +/// `Re: Re: AW:`. +/// +/// `Re[2]:` and `Re(2):` are the counted forms Outlook and some list managers +/// emit. They mean the same thing and must not be doubled either. +/// +/// **Single-letter spellings are deliberately NOT here**, though Italian +/// clients do send `R:`. Measured 2026-08-21: with `r` in the alternation, +/// `R: report on Q3` reads as a reply prefix, so a genuine first reply to that +/// subject gets no `Re:` and threads nowhere in the recipient's client. A +/// one-letter token before a colon is an ordinary subject far more often than +/// it is a prefix, and this is the same failure the anchoring note above +/// describes. The cost of omitting it is one doubled `Re: R:`, which is +/// cosmetic; the cost of including it is broken threading, which is not. +/// +/// Ordering inside the alternation matters: `res` before `re` so the longer +/// spelling is not consumed by the shorter one, leaving a stray `S:` unmatched. +const QRegularExpression &replyPrefix() +{ + static const QRegularExpression expression( + QStringLiteral("^\\s*(res|re|aw|antw|sv|vs)\\s*(\\[\\d+\\]|\\(\\d+\\))?\\s*:"), + QRegularExpression::CaseInsensitiveOption); + return expression; +} + +/// "Fwd:" and "Fw:" mean the same thing and both are common, as do the +/// non-English spellings a mixed-locale mailbox receives: German `WG:`, Spanish +/// and Portuguese `RV:` and `ENC:`, French `TR:`. Same reasoning as +/// replyPrefix(): an English-only pattern produces `Fwd: WG: subject`. +/// +/// Italian `I:` is omitted for the reason replyPrefix() omits `R:`, and it is +/// the worse of the two: `I: notes` is an entirely ordinary subject. The +/// previous pattern was `fwd?`, which likewise matched a bare `F:`; that is not +/// a forward marker in any client and `F: results` must still get a prefix. +const QRegularExpression &forwardPrefix() +{ + static const QRegularExpression expression( + QStringLiteral("^\\s*(fwd|fw|wg|enc|rv|tr)\\s*(\\[\\d+\\]|\\(\\d+\\))?\\s*:"), + QRegularExpression::CaseInsensitiveOption); + return expression; +} + +/// The account whose maildir contains \p path, or empty. +QString accountOwning(const Config &config, const QString &path, + const QString &mailRoot) +{ + for (const Account &account : config.accounts()) { + if (account.maildir.isEmpty()) + continue; + const QString prefix = + QDir(mailRoot).absoluteFilePath(account.maildir) + QLatin1Char('/'); + // Compared as a path prefix with the separator INCLUDED: without the + // trailing slash an account "work" would also match a maildir + // "work-archive", and the reply would be sent from the wrong account. + if (path.startsWith(prefix)) + return account.key; + } + return {}; +} + +/// True when \p address is one of \p ownAddresses, compared case-insensitively. +/// +/// Compared on the addr-spec, never on a rendered "Name <addr>": a display +/// name may legitimately contain an address-looking substring, and a substring +/// test against the whole form strips a real recipient whose name happens to +/// quote one of the user's addresses. +bool isOwn(const QString &address, const QStringList &ownAddresses) +{ + for (const QString &own : ownAddresses) { + if (own.isEmpty()) + continue; + if (address.compare(own, Qt::CaseInsensitive) == 0) + return true; + } + return false; +} + +/// Appends \p recipient to \p out unless its address is already in \p seen or +/// belongs to the user. \p seen is updated. +/// +/// Deduplication is keyed on the lowercased ADDRESS, so the same mailbox under +/// two different display names counts once, which is what the original's To +/// and Cc routinely contain. +void appendUnlessSuppressed(const ComposeContextBuilder::Recipient &recipient, + const QStringList &ownAddresses, + QSet<QString> *seen, QStringList *out) +{ + if (recipient.address.isEmpty()) + return; + const QString key = recipient.address.toLower(); + if (seen->contains(key)) + return; + if (isOwn(recipient.address, ownAddresses)) + return; + seen->insert(key); + out->append(recipient.rendered); +} + +} // namespace + +QList<ComposeContextBuilder::Recipient> +ComposeContextBuilder::parseAddressHeader(const QString &rawHeader) +{ + ensureGMimeInitialised(); + + const QByteArray utf8 = rawHeader.trimmed().toUtf8(); + if (utf8.isEmpty()) + return {}; + + // Returns NULL rather than an empty list for input it can make nothing of, + // including the empty string. Guarded above and again here: the header is + // untrusted and this is the crash if it is not. + InternetAddressList *list = internet_address_list_parse(nullptr, utf8.constData()); + if (!list) + return {}; + + QList<Recipient> recipients; + const int count = internet_address_list_length(list); + for (int i = 0; i < count; ++i) { + InternetAddress *address = internet_address_list_get_address(list, i); + if (!address) + continue; + + // Only MAILBOXES. A group carries a name and no address, so keeping it + // would put "undisclosed-recipients" in a To field as though it were a + // person. It is also the injection defence: measured 2026-08-21, a raw + // newline smuggled into a header makes GMime parse the following + // "Bcc: evil@example.net" as a GROUP, and dropping non-mailboxes drops + // it rather than pre-filling a recipient the user never saw. + if (!INTERNET_ADDRESS_IS_MAILBOX(address)) + continue; + + const char *addr = + internet_address_mailbox_get_addr(INTERNET_ADDRESS_MAILBOX(address)); + if (!addr || !*addr) + continue; + + Recipient recipient; + recipient.address = QString::fromUtf8(addr).trimmed(); + if (recipient.address.isEmpty()) + continue; + + // Rendered BY GMIME rather than assembled by string. Quoting a display + // name is not a matter of wrapping it in quotes: a name containing a + // comma must come back out quoted or it re-parses as two recipients. + // + // The final argument is ENCODE, and it is a security parameter rather + // than a formatting preference. With FALSE a display name carrying a + // raw newline renders with that newline intact, which is a + // header-injection primitive: `"foo\nBcc: evil@example.net" <a@...>` + // comes back out verbatim and anything writing it into a To: line + // emits a second header the user never saw. With TRUE the same input + // renders RFC 2047 encoded as `=?iso-8859-1?q?foo=0ABcc=3A?= ...` and + // the newline can no longer terminate a header. Measured 2026-08-21; + // this shipped as FALSE and the test caught it. + char *rendered = internet_address_to_string( + address, g_mime_format_options_get_default(), TRUE); + recipient.rendered = rendered ? QString::fromUtf8(rendered).trimmed() + : QString(); + g_free(rendered); + if (recipient.rendered.isEmpty()) + recipient.rendered = recipient.address; + + recipients.append(recipient); + } + g_object_unref(list); + + return recipients; +} + +QStringList ComposeContextBuilder::ownAddresses(const Config &config) +{ + QStringList addresses; + for (const Account &account : config.accounts()) { + const QString address = account.address.trimmed(); + // An empty address is dropped rather than collected. It would match + // nothing usefully and, in any substring comparison, everything. + if (!address.isEmpty() && !addresses.contains(address, Qt::CaseInsensitive)) + addresses.append(address); + } + return addresses; +} + +void ComposeContextBuilder::recipientsForReply(const ParsedMessage &message, + bool replyAll, + const QStringList &ownAddresses, + QStringList *toOut, + QStringList *ccOut) +{ + if (toOut) + toOut->clear(); + if (ccOut) + ccOut->clear(); + if (!toOut) + return; + + // Reply-To wins over From when present (RFC 5322 3.6.2: it names where the + // author wants replies sent). Applied to reply-all as well as to a plain + // reply: a list's reply-all belongs on the list too. + QList<Recipient> sender = parseAddressHeader(message.replyTo); + if (sender.isEmpty()) + sender = parseAddressHeader(message.from); + + // A reply to the user's OWN message goes to the people that message was + // addressed to, not back to the user. Reached from the Sent view, from a + // follow-up on unanswered mail, and from any thread whose selected row is + // the user's own message, so it is an ordinary gesture rather than an edge + // case. The alternative considered and rejected was stripping the sender + // and leaving To empty, which silently drops every recipient and looks + // sendable. + // + // "Own" means EVERY parsed sender address is the user's. A message with a + // co-sender is still a reply to that co-sender. + bool senderIsSelf = !sender.isEmpty(); + for (const Recipient &recipient : sender) { + if (!isOwn(recipient.address, ownAddresses)) { + senderIsSelf = false; + break; + } + } + + QSet<QString> seen; + if (senderIsSelf) { + // The original's To, with own addresses removed. Its Cc is deliberately + // NOT taken here for a reply-all: the split is the message's meaning, + // To being "addressed to you" and Cc "for information", and promoting a + // Cc'd party to To is visible to every recipient. The Cc pass below + // carries them across unchanged, so the reply mirrors the original. + // + // A PLAIN reply has no Cc field to mirror into, so it takes To and Cc + // together: everyone who was on the message is still addressed, which + // is what a reply to a conversation the user started means. + // + // Mail the user sent to themselves alone leaves nothing after the own + // filter, which is the one case where addressing the user IS correct, + // so the sender is restored below rather than producing an empty To. + QStringList headers = { message.to }; + if (!replyAll) + headers.append(message.cc); + for (const QString &header : headers) { + const QList<Recipient> parsed = parseAddressHeader(header); + for (const Recipient &recipient : parsed) + appendUnlessSuppressed(recipient, ownAddresses, &seen, toOut); + } + } + + if (toOut->isEmpty()) { + for (const Recipient &recipient : sender) { + if (recipient.address.isEmpty()) + continue; + const QString key = recipient.address.toLower(); + if (seen.contains(key)) + continue; + // The sender is NOT filtered against the user's own addresses + // here. This branch is reached either for an ordinary reply, where + // the sender is somebody else, or for a note the user sent only to + // themselves, where they are the correct recipient. Stripping in + // either case produces a message with no recipient that still + // looks sendable. + seen.insert(key); + toOut->append(recipient.rendered); + } + } + + // A From that parses to no mailbox at all leaves To empty, and that is + // reachable from real mail rather than only from a hostile fixture: a bare + // display name with no angle brackets ("From: Mailer Daemon") is what + // bounces and some automated senders emit, and MimeParser hands it over as + // a header with zero mailboxes. An empty To is the worst outcome available, + // since MessageBuilder treats it as success: the message is handed to the + // send command with nobody to deliver to and a copy is filed in Sent that + // looks sent and reached no one. + // + // The original's recipients are the only remaining candidates. Own + // addresses are stripped, so a message the user sent AND that has an + // unparseable From still yields nothing here, which is correct: there is + // genuinely nobody to address, and the composer shows an empty To the user + // can see and fill rather than a wrong one they will not check. + if (toOut->isEmpty()) { + for (const QString &header : { message.to, message.cc }) { + const QList<Recipient> parsed = parseAddressHeader(header); + for (const Recipient &recipient : parsed) + appendUnlessSuppressed(recipient, ownAddresses, &seen, toOut); + } + } + + if (!replyAll || !ccOut) + return; + + // Everyone else goes to Cc, with the user's own addresses removed and + // duplicates suppressed ACROSS the two fields rather than within each: the + // sender is very often also in the original's To, and per-field + // deduplication lists them twice. + for (const QString &header : { message.to, message.cc }) { + const QList<Recipient> parsed = parseAddressHeader(header); + for (const Recipient &recipient : parsed) + appendUnlessSuppressed(recipient, ownAddresses, &seen, ccOut); + } +} + +QStringList ComposeContextBuilder::referencesForReply(const ParsedMessage &message) +{ + QStringList references; + QSet<QString> seen; + + const auto append = [&references, &seen](const QString &raw) { + QString id = raw.trimmed(); + if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>'))) + id = id.mid(1, id.size() - 2).trimmed(); + if (id.isEmpty() || seen.contains(id)) + return; + seen.insert(id); + references.append(id); + }; + + // The header is a whitespace-separated run of <message-ids>, and real mail + // wraps it across lines, so whitespace is the conformant separator. + // + // Commas are accepted BESIDES whitespace because some clients emit + // `<a@x>,<b@y>`, which RFC 5322 does not allow here. Splitting on + // whitespace alone turns that whole header into ONE token, and the bracket + // strip below then yields the garbage id `a@x>,<b@y`: not a threading + // degradation but a fabricated Message-ID sent to the recipient's client. + // A comma cannot appear inside a msg-id, so accepting it costs nothing. + const QStringList existing = message.references.split( + QRegularExpression(QStringLiteral("[\\s,]+")), Qt::SkipEmptyParts); + for (const QString &id : existing) + append(id); + + // The original's own id goes LAST, which is what makes the chain an order + // rather than a set. Appended through the same deduplication, so a message + // whose References already names it does not repeat it. + append(message.messageId); + + return references; +} + +QString ComposeContextBuilder::accountForReply(const Config &config, + const QStringList &messagePaths, + const QStringList &recipients, + const QString &mailRoot) +{ + QStringList candidates; + for (const QString &path : messagePaths) { + const QString key = accountOwning(config, path, mailRoot); + if (!key.isEmpty() && !candidates.contains(key)) + candidates.append(key); + } + + if (candidates.isEmpty()) + return {}; + if (candidates.size() == 1) + return candidates.first(); + + // Ambiguous: the same message in more than one maildir. Prefer the account + // whose own address appears among the recipients, which is the reason the + // copy landed there. + for (const QString &key : candidates) { + const Account account = config.account(key); + if (account.address.isEmpty()) + continue; + for (const QString &recipient : recipients) { + if (recipient.contains(account.address, Qt::CaseInsensitive)) + return key; + } + } + + // Arbitrary, and visible: the From field shows the choice. + return candidates.first(); +} + +QString ComposeContextBuilder::accountForNew(const Config &config, + const QString &selectedAccount) +{ + const auto canSend = [&config](const QString &key) { + if (key.isEmpty()) + return false; + for (const Account &account : config.accounts()) { + if (account.key == key) + return account.canSend(); + } + return false; + }; + + // 1. The dropdown's current account, when it is a specific one that can send. + if (canSend(selectedAccount)) + return selectedAccount; + + // 2. [compose] default_account. + if (canSend(config.compose().defaultAccount)) + return config.compose().defaultAccount; + + // 3. [general] startup_account, on the same condition. + if (canSend(config.startupAccount())) + return config.startupAccount(); + + // 4. The first account in configuration order that can send. Arbitrary, + // which is exactly why rules 2 and 3 exist. + const QList<Account> sending = config.sendingAccounts(); + if (!sending.isEmpty()) + return sending.first().key; + + // No account can send. A valid read-only installation; the caller's action + // is disabled and should never have reached this. + return {}; +} + +QString ComposeContextBuilder::replySubject(const QString &original) +{ + if (replyPrefix().match(original).hasMatch()) + return original; + return QStringLiteral("Re: ") + original; +} + +QString ComposeContextBuilder::forwardSubject(const QString &original) +{ + if (forwardPrefix().match(original).hasMatch()) + return original; + return QStringLiteral("Fwd: ") + original; +} + +QString ComposeContextBuilder::quoteBody(const ParsedMessage &message) +{ + QStringList quoted; + + // The attribution line. Deliberately NOT translated and NOT reformatted + // through a locale-dependent date format: this text is sent to a recipient + // who may not share the user's locale, and the raw Date header is what + // every other client quotes. + quoted.append(QStringLiteral("On %1, %2 wrote:") + .arg(message.date, message.from)); + quoted.append(QString()); + + // Normalised to LF first. A CRLF body split on '\n' alone leaves a + // carriage return at the end of every line, which survives into the sent + // message as a stray CR in the middle of a quoted line. + QString body = message.plainBody; + body.replace(QStringLiteral("\r\n"), QStringLiteral("\n")); + body.replace(QLatin1Char('\r'), QLatin1Char('\n')); + + const QStringList lines = body.split(QLatin1Char('\n')); + for (const QString &line : lines) { + // A blank line still carries the marker. Without it the quote visually + // ends there in every client that renders quoting. + quoted.append(line.isEmpty() ? QStringLiteral(">") + : QStringLiteral("> ") + line); + } + + return quoted.join(QLatin1Char('\n')); +} diff --git a/src/composecontext.h b/src/composecontext.h new file mode 100644 index 0000000..4027af0 --- /dev/null +++ b/src/composecontext.h @@ -0,0 +1,177 @@ +/* + * 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 <QList> +#include <QString> +#include <QStringList> + +#include "types.h" + +struct Account; +class Config; +struct ParsedMessage; + +/// Builds the ComposeContext that opens a composer. +/// +/// Free functions in a namespace: this is pure logic over values, and keeping +/// it apart from ComposeWindow is what lets recipient derivation, subject +/// prefixing and account resolution be tested without a painter. +namespace ComposeContextBuilder { + +/// One recipient split out of a header, as both parts and as a rendered whole. +/// +/// Kept as a struct rather than a bare string because the two halves answer +/// two different questions and conflating them is how the user's own address +/// escapes a filter. `address` is what a comparison must use: a display name +/// may legitimately CONTAIN an address-looking substring, and a substring test +/// against the whole rendered form matches "not-me@example.org" for "me@example.org". +/// `rendered` is what goes in the field the user sees. +struct Recipient +{ + QString address; ///< The bare addr-spec, no display name, no angle brackets. + QString rendered; ///< "Name <addr>" or the bare address when it has no name. +}; + +/// The addresses belonging to the user, across every configured account. +/// +/// Every one of them is stripped from a reply-all's recipients. Missing one +/// means the user receives their own reply, which is the failure this is most +/// likely to have. +QStringList ownAddresses(const Config &config); + +/// Splits a raw address header into individual recipients, using GMime. +/// +/// NEVER split on commas. A display name may contain one, so +/// `"Rossi, Mario" <m@example.org>, info@example.net` is TWO addresses and a +/// naive split reports three, one of which ("Rossi") is not an address at all +/// and would be handed to the send command as a recipient. This is the same +/// reason `recipientSummary()` in mimeparser.cpp parses rather than splits. +/// +/// Groups (`undisclosed-recipients:;`) contribute NOTHING. A group carries a +/// name and no mailbox, so naming it would put "undisclosed-recipients" in a +/// To field as though it were a person. This also closes a header-injection +/// shape: a raw newline in a header value makes GMime parse the smuggled +/// `Bcc: evil@example.net` as a GROUP, measured 2026-08-21, so dropping +/// non-mailboxes drops the injected recipient rather than carrying it forward. +/// +/// An unparseable header yields an empty list rather than a partial guess. +QList<Recipient> parseAddressHeader(const QString &rawHeader); + +/// Who a reply goes to, as \p toOut and \p ccOut. +/// +/// This is the function the spec calls out as where the subtle bugs live, and +/// the rules are not interchangeable: +/// +/// - **Reply** goes to the ORIGINAL SENDER only, and Cc is empty. Reply-To +/// takes precedence over From when the original carries one (RFC 5322 +/// §3.6.2: it names where the author wants replies sent), which is what +/// makes a mailing list's reply land on the list rather than on a person who +/// never asked to be written to directly. +/// - **Reply-all** puts the sender in To, and the original's To and Cc in Cc. +/// The user's own addresses are stripped from BOTH, or they receive their +/// own reply. Comparison is case-insensitive: an address's domain is +/// case-insensitive by RFC and real mail varies the local part's case too, +/// so a case-sensitive filter lets `User@Example.org` through against a +/// configured `user@example.org`. +/// - A duplicate is suppressed ACROSS To and Cc, not within each: the sender +/// is very often also in the original's To, and listing them twice is what +/// naive per-field deduplication produces. +/// +/// \p replyAll false yields sender-only. \p ownAddresses is what +/// ownAddresses(config) returned. +/// +/// **A reply to the user's OWN message goes where that message went**, not +/// back to the user: To comes from the original's recipients instead of from +/// its sender. A plain reply takes its To and Cc together, having no Cc field +/// of its own to mirror into; a reply-all MIRRORS THE SPLIT, the original's To +/// becoming To and its Cc becoming Cc, because To means "addressed to you" and +/// Cc "for information" and promoting a Cc'd party to To is visible to every +/// recipient. This is reached from the Sent view, from a follow-up on +/// unanswered mail, and from any thread whose selected row is the user's own +/// message, so it is an ordinary gesture. "Own" means EVERY parsed sender +/// address is the user's; a co-sender is still someone to reply to. +/// +/// Mail the user sent to THEMSELVES alone leaves nothing after that filter, and +/// there the sender is restored: the user is the correct recipient of their own +/// note. Emptying To instead would produce a message with no recipient that +/// still looks sendable, which is why stripping the sender was rejected as the +/// fix. Nothing else strips an own address from a plain Reply's To. +void recipientsForReply(const ParsedMessage &message, bool replyAll, + const QStringList &ownAddresses, + QStringList *toOut, QStringList *ccOut); + +/// The References header for a reply: the original's References plus its +/// Message-ID. +/// +/// Not optional. Without it a reply appears as an orphan thread in the +/// sender's own client. A duplicate Message-ID is not appended twice. +/// +/// Ids come back BARE, without angle brackets, matching what GMime hands back +/// when MimeParser reads a `Message-ID`. The brackets are wire syntax and +/// `MessageBuilder` adds them when it writes the header, in one place rather +/// than in each caller: GMime writes an EMPTY header for a bare addr-spec +/// rather than complaining, so a caller that forgets them ships a reply that +/// threads nowhere while nothing looks wrong locally. +QStringList referencesForReply(const ParsedMessage &message); + +/// Which account replies to a message whose file lives at \p messagePaths. +/// +/// The displayed message's own maildir is the strongest available signal and +/// wins outright: mail sent to an address landed in that address's maildir, so +/// replying from it is what the recipient expects. The account dropdown is NOT +/// consulted. +/// +/// A message can be in more than one maildir: on a list twice under two +/// addresses, or duplicated across accounts by mbsync, and notmuch returns +/// several filenames for one id. \p recipients disambiguates by preferring the +/// account matching a To or Cc entry; failing that the first is taken. The From +/// field shows the choice, so an arbitrary resolution is visible rather than +/// hidden. +QString accountForReply(const Config &config, const QStringList &messagePaths, + const QStringList &recipients, const QString &mailRoot); + +/// Which account a NEW message comes from, by the four fallback rules. +/// +/// \p selectedAccount is the dropdown's current account, empty for All +/// accounts. Returns empty only when no account can send at all. +QString accountForNew(const Config &config, const QString &selectedAccount); + +/// `Re:` or `Fwd:` prefixed, without doubling an existing prefix. +/// +/// An existing prefix is recognised in the non-English spellings a mixed-locale +/// mailbox receives (`AW:`, `SV:`, `RES:`, `WG:`, `TR:`, `RV:`, `ENC:`) and in +/// the counted forms Outlook emits (`Re[2]:`, `Re(3):`), or every one of those +/// doubles into `Re: AW: subject`. +/// +/// Single-letter spellings are deliberately NOT recognised, though Italian +/// clients send `R:` and `I:`: `R: report on Q3` is an ordinary subject, and +/// treating it as a prefix means a genuine first reply gets no `Re:` and +/// threads nowhere. See the patterns in composecontext.cpp for the measurement. +QString replySubject(const QString &original); +QString forwardSubject(const QString &original); + +/// The `>`-prefixed original, with an attribution line. +/// +/// Takes a ParsedMessage, NOT a MessageNode: the node carries no body and no +/// date (it holds messageId, threadId, from, subject, tags, filePath and +/// depth), so quoting has to come from what MimeParser produced. +QString quoteBody(const ParsedMessage &message); + +} // namespace ComposeContextBuilder diff --git a/src/composewindow.cpp b/src/composewindow.cpp new file mode 100644 index 0000000..0445c5d --- /dev/null +++ b/src/composewindow.cpp @@ -0,0 +1,919 @@ +/* + * 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 "composewindow.h" + +#include <QTemporaryDir> + +#include "draftstore.h" +#include "messagebuilder.h" +#include "mimeparser.h" +#include "messagesender.h" +#include "senddialog.h" + +#include <QAction> +#include <QCheckBox> +#include <QCloseEvent> +#include <QComboBox> +#include <QDir> +#include <QFile> +#include <QFileDialog> +#include <QFileInfo> +#include <QFormLayout> +#include <QHBoxLayout> +#include <QKeySequence> +#include <QLabel> +#include <QLineEdit> +#include <QListWidget> +#include <QMessageBox> +#include <QPlainTextEdit> +#include <QPushButton> +#include <QTextCursor> +#include <QTimer> +#include <QToolBar> +#include <QVBoxLayout> +#include <QWidget> + +namespace { + +/// Splits a comma-separated recipient field into addresses. +/// +/// Splitting on commas is WRONG for a raw header, which is why +/// ComposeContextBuilder::parseAddressHeader parses instead. It is right here +/// and only here: this is a field the user typed, and the composer's own +/// rendering of it joins with ", ". A display name containing a comma has to +/// be quoted by the user, exactly as it has to be in the wire format, and +/// MessageBuilder is what turns each entry into a mailbox. +QStringList splitRecipients(const QString &text) +{ + QStringList out; + const QStringList parts = text.split(QLatin1Char(','), Qt::SkipEmptyParts); + for (const QString &part : parts) { + const QString trimmed = part.trimmed(); + if (!trimmed.isEmpty()) + out.append(trimmed); + } + return out; +} + +/// Everything about a message the user can change, as one comparable string. +/// +/// Joined with a character no field can contain, because concatenating them +/// bare lets a change move a boundary without changing the whole: a subject +/// "ab" with body "c" and a subject "a" with body "bc" would produce the same +/// string and the second edit would never be saved. A unit separator (U+001F) +/// cannot be typed into a QLineEdit or a QPlainTextEdit and cannot appear in a +/// file path. +QString fingerprintOf(const OutgoingMessage &message) +{ + const QChar sep(QChar(0x1F)); + return message.accountKey + sep + message.to.join(sep) + sep + + message.cc.join(sep) + sep + message.bcc.join(sep) + sep + + message.subject + sep + message.markdownBody + sep + + (message.sendHtml ? QStringLiteral("1") : QStringLiteral("0")) + sep + + message.attachments.join(sep); +} + +} // namespace + +ComposeWindow::ComposeWindow(const ComposeContext &context, + const Config &config, const QString &mailRoot, + QWidget *parent) + : QMainWindow(parent) + , m_context(context) + , m_config(config) + , m_mailRoot(mailRoot) + , m_attachments(context.attachments) +{ + // A window in its own right, not a child dialog: it must appear in the + // task switcher and be reachable while the main window is used. Passing a + // parent still makes Qt treat it as a window because of Qt::Window, which + // QMainWindow carries. + setAttribute(Qt::WA_DeleteOnClose); + setWindowTitle(tr("Compose")); + + // A sensible default. NOT restored and NOT saved; see the header. + resize(760, 640); + + // BEFORE buildUi(), and this ordering is load-bearing rather than + // stylistic. buildUi() connects every field to markDirty(), and seeding + // then fills those fields, so markDirty() runs during construction and + // calls m_autosaveTimer->start(). Created afterwards, that is a null + // dereference on the first seeded field, which is every composer. + m_autosaveTimer = new QTimer(this); + m_autosaveTimer->setObjectName(QStringLiteral("autosave")); + m_autosaveTimer->setSingleShot(true); + m_autosaveTimer->setInterval(m_config.compose().autosaveIntervalMs); + connect(m_autosaveTimer, &QTimer::timeout, this, &ComposeWindow::autosave); + + m_sender = new MessageSender(this); + + buildUi(); + buildFormatToolbar(); + seedFields(); + seedBody(); + + // AFTER buildUi(), which creates m_banner, and BEFORE + // refreshAttachmentList(), which renders m_attachments: extraction appends + // to that list, so listing first would show a Forward with no attachments + // on it, which is precisely the defect this fixes. + extractForwardedAttachments(); + + refreshAttachmentList(); + + // Seeding is not an edit. Every field was just filled from the context, so + // the widgets have emitted their change signals and left the window dirty + // before the user has typed anything; a composer opened and closed at once + // would then write a draft nobody asked for. The timer is stopped as well + // as the flag cleared, since markDirty() started it. + m_dirty = false; + m_autosaveTimer->stop(); +} + + +ComposeWindow::~ComposeWindow() = default; + +void ComposeWindow::extractForwardedAttachments() +{ + if (m_context.kind != ComposeContext::Kind::Forward + || m_context.originalPath.isEmpty()) { + return; + } + + MimeParser parser; + const ParsedMessage original = parser.parse(m_context.originalPath); + if (!original.ok || original.attachments.isEmpty()) + return; + + m_forwardedParts = std::make_unique<QTemporaryDir>(); + if (!m_forwardedParts->isValid()) { + m_forwardedParts.reset(); + m_banner->setText( + tr("The forwarded attachments could not be extracted.")); + m_banner->show(); + return; + } + + // Not auto-removed on destruction by accident: QTemporaryDir does this by + // default, and it is the whole reason the directory rather than the files + // is what this window owns. + m_forwardedParts->setAutoRemove(true); + + QStringList failed; + for (const Attachment &attachment : original.attachments) { + QString error; + // saveWithoutOverwriting, never saveTo. One message really can carry + // two parts with the same filename, and saveTo overwrites: CLAUDE.md + // records six of sixteen files lost that way, every write reporting + // success. Here it would silently forward fewer files than the + // original had. + const QString written = + attachment.saveWithoutOverwriting(m_forwardedParts->path(), &error); + if (written.isEmpty()) { + failed.append(attachment.safeFilename()); + continue; + } + m_attachments.append(written); + } + + if (!failed.isEmpty()) { + // Said out loud rather than swallowed. The composer looks entirely + // correct with an attachment missing, and the recipient gets a body + // quoting a document that is not there. + m_banner->setText( + tr("%n forwarded attachment(s) could not be extracted: %1", "", + failed.size()) + .arg(failed.join(QStringLiteral(", ")))); + m_banner->show(); + } +} + +Account ComposeWindow::currentAccount() const +{ + // The dropdown is the authority once the window is open: the context + // chooses the initial account and the user may then change it, and every + // build after that must use what the From field shows. Reading + // m_context.accountKey here instead would send from the seeded account + // however the dropdown was set, with the interface saying otherwise. + if (m_from && m_from->currentIndex() >= 0) { + const QString key = m_from->currentData().toString(); + if (!key.isEmpty()) + return m_config.account(key); + } + return m_config.account(m_context.accountKey); +} + +void ComposeWindow::buildUi() +{ + auto *central = new QWidget(this); + central->setObjectName(QStringLiteral("composeCentral")); + auto *layout = new QVBoxLayout(central); + + // The failed-save banner, above everything: a warning that must survive + // until it is dealt with does not belong below the fold. Hidden until + // there is something to say. + m_banner = new QLabel(central); + m_banner->setObjectName(QStringLiteral("draftBanner")); + m_banner->setWordWrap(true); + // PlainText explicitly. The text carries a filesystem error string and a + // path, neither of which is ours, and a QLabel guesses under AutoText. + m_banner->setTextFormat(Qt::PlainText); + m_banner->hide(); + layout->addWidget(m_banner); + + auto *form = new QFormLayout; + + m_from = new QComboBox(central); + m_from->setObjectName(QStringLiteral("from")); + form->addRow(tr("From:"), m_from); + + m_to = new QLineEdit(central); + m_to->setObjectName(QStringLiteral("to")); + form->addRow(tr("To:"), m_to); + + m_cc = new QLineEdit(central); + m_cc->setObjectName(QStringLiteral("cc")); + form->addRow(tr("Cc:"), m_cc); + + m_bcc = new QLineEdit(central); + m_bcc->setObjectName(QStringLiteral("bcc")); + form->addRow(tr("Bcc:"), m_bcc); + + m_subject = new QLineEdit(central); + m_subject->setObjectName(QStringLiteral("subject")); + form->addRow(tr("Subject:"), m_subject); + + layout->addLayout(form); + + // Labelled for what it does, a formatted copy riding along with the plain + // text, rather than "HTML", which reads as an either/or that it is not. + m_sendHtml = new QCheckBox(tr("Also send a formatted copy"), central); + m_sendHtml->setObjectName(QStringLiteral("sendHtml")); + m_sendHtml->setToolTip( + tr("Sends the message as plain text with a formatted version " + "alongside it. The plain text is what you typed.")); + layout->addWidget(m_sendHtml); + + m_body = new QPlainTextEdit(central); + m_body->setObjectName(QStringLiteral("body")); + layout->addWidget(m_body, 1); + + m_attachmentList = new QListWidget(central); + m_attachmentList->setObjectName(QStringLiteral("attachments")); + m_attachmentList->setMaximumHeight(90); + m_attachmentList->hide(); + layout->addWidget(m_attachmentList); + + // The send-failure pane, in the shape MainWindow's sync log already has: + // a header with a Close button and a read-only QPlainTextEdit under it. A + // QPlainTextEdit has no close affordance of its own, so the two travel + // together as one widget. + m_sendLogPane = new QWidget(central); + m_sendLogPane->setObjectName(QStringLiteral("sendLogPane")); + auto *logLayout = new QVBoxLayout(m_sendLogPane); + logLayout->setContentsMargins(0, 0, 0, 0); + logLayout->setSpacing(2); + + auto *logHeader = new QHBoxLayout; + logHeader->addWidget(new QLabel(tr("Send output"), m_sendLogPane)); + logHeader->addStretch(); + auto *closeLog = new QPushButton(tr("Close"), m_sendLogPane); + closeLog->setObjectName(QStringLiteral("closeSendLog")); + connect(closeLog, &QPushButton::clicked, m_sendLogPane, &QWidget::hide); + logHeader->addWidget(closeLog); + logLayout->addLayout(logHeader); + + m_sendLog = new QPlainTextEdit(m_sendLogPane); + m_sendLog->setObjectName(QStringLiteral("sendLog")); + m_sendLog->setReadOnly(true); + m_sendLog->setMaximumHeight(140); + logLayout->addWidget(m_sendLog); + + m_sendLogPane->hide(); + layout->addWidget(m_sendLogPane); + + setCentralWidget(central); + + // Every field marks the buffer dirty. The subject and the recipients are + // part of the message as much as the body is, and a draft that saved the + // body but not the address it was going to would be worse than none. + connect(m_body, &QPlainTextEdit::textChanged, this, + &ComposeWindow::markDirty); + 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); +} + +void ComposeWindow::buildFormatToolbar() +{ + m_formatToolbar = addToolBar(tr("Formatting")); + m_formatToolbar->setObjectName(QStringLiteral("formatToolbar")); + + // A QAction parented to THIS WINDOW, not registered in KeyMap. Its + // shortcut is therefore scoped to the composer: Qt dispatches a + // WindowShortcut to the active window only, so the main window's Ctrl+B is + // untouched and the two namespaces stay apart. These six do not + // participate in item 132's reachability rule for the same reason. + const auto addFormat = [this](const QString &name, const QString &text, + const QString &token, + const QKeySequence &shortcut) { + QAction *action = m_formatToolbar->addAction(text); + action->setObjectName(name); + if (!shortcut.isEmpty()) + action->setShortcut(shortcut); + connect(action, &QAction::triggered, this, + [this, token]() { applyFormat(token); }); + }; + + addFormat(QStringLiteral("format_bold"), tr("Bold"), + QStringLiteral("**"), QKeySequence(QStringLiteral("Ctrl+B"))); + addFormat(QStringLiteral("format_italic"), tr("Italic"), + QStringLiteral("*"), QKeySequence(QStringLiteral("Ctrl+I"))); + addFormat(QStringLiteral("format_code"), tr("Code"), + QStringLiteral("`"), QKeySequence(QStringLiteral("Ctrl+`"))); + // No shortcut, per the spec's table. + addFormat(QStringLiteral("format_strike"), tr("Strikethrough"), + QStringLiteral("~~"), QKeySequence()); + + // Link and Quote are not wraps and cannot go through applyFormat(). + QAction *link = m_formatToolbar->addAction(tr("Link")); + link->setObjectName(QStringLiteral("format_link")); + link->setShortcut(QKeySequence(QStringLiteral("Ctrl+K"))); + connect(link, &QAction::triggered, this, [this]() { + const QTextCursor cursor = m_body->textCursor(); + applyEdit(MarkdownFormat::link(m_body->toPlainText(), + cursor.selectionStart(), + cursor.selectionEnd())); + }); + + QAction *quote = m_formatToolbar->addAction(tr("Quote")); + quote->setObjectName(QStringLiteral("format_quote")); + connect(quote, &QAction::triggered, this, [this]() { + const QTextCursor cursor = m_body->textCursor(); + applyEdit(MarkdownFormat::quote(m_body->toPlainText(), + cursor.selectionStart(), + cursor.selectionEnd())); + }); + + m_formatToolbar->addSeparator(); + + m_attachAction = m_formatToolbar->addAction(tr("Attach...")); + m_attachAction->setObjectName(QStringLiteral("compose_attach")); + connect(m_attachAction, &QAction::triggered, this, [this]() { + const QStringList chosen = QFileDialog::getOpenFileNames( + this, tr("Attach files")); + for (const QString &path : chosen) + attachFile(path); + }); + + m_detachAction = m_formatToolbar->addAction(tr("Remove attachment")); + m_detachAction->setObjectName(QStringLiteral("compose_detach")); + connect(m_detachAction, &QAction::triggered, this, [this]() { + const int row = m_attachmentList->currentRow(); + if (row < 0 || row >= m_attachments.size()) + return; + m_attachments.removeAt(row); + refreshAttachmentList(); + markDirty(); + }); + + m_sendAction = m_formatToolbar->addAction(tr("Send")); + m_sendAction->setObjectName(QStringLiteral("compose_send")); + m_sendAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Return"))); + connect(m_sendAction, &QAction::triggered, this, &ComposeWindow::send); +} + +void ComposeWindow::seedFields() +{ + // Only accounts that can send. An account without a send_command is + // receive-only by construction, and offering it in a From field would + // produce a message that cannot be sent from the account it says it is + // from. + const QList<Account> senders = m_config.sendingAccounts(); + for (const Account &account : senders) { + const QString label = account.name.isEmpty() + ? account.address + : account.name + QStringLiteral(" <") + + account.address + QLatin1Char('>'); + m_from->addItem(label, account.key); + } + const int index = m_from->findData(m_context.accountKey); + if (index >= 0) + m_from->setCurrentIndex(index); + + m_to->setText(m_context.to.join(QStringLiteral(", "))); + m_cc->setText(m_context.cc.join(QStringLiteral(", "))); + m_subject->setText(m_context.subject); + + // New and Forward seed from [compose] send_html; Reply and Reply-all seed + // from whether the original carried a text/html part, ignoring the config + // value. An HTML part in the original is a fact about the sender's + // software, not a guess about their taste. + const bool isReply = m_context.kind == ComposeContext::Kind::Reply + || m_context.kind == ComposeContext::Kind::ReplyAll; + m_sendHtml->setChecked(isReply ? m_context.seedHtml + : m_config.compose().sendHtml); +} + +void ComposeWindow::seedBody() +{ + if (m_context.quotedBody.isEmpty()) + return; + + // Applied when the window opens and never again. The buffer is text the + // user owns after that, and there is deliberately no live toggle: + // tracking "my text" and "the quote" as separate pieces to make a toggle + // reversible is machinery for a case answered by closing the composer and + // reopening it. + if (m_config.compose().quotePosition + == ComposeSettings::QuotePosition::Above) { + // The quote first, then a blank line for the reply to be typed into. + m_body->setPlainText(m_context.quotedBody + QStringLiteral("\n\n")); + } else { + m_body->setPlainText(QStringLiteral("\n\n") + m_context.quotedBody); + } + + // The cursor at the very top in both cases: with the quote below, the + // blank lines the reply goes into are at the top; with it above, the user + // scrolls past what they are answering, which is what quoting above means. + m_body->moveCursor(QTextCursor::Start); + + // The seeded quote 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 the + // quote and read as the buffer losing its content. + m_body->document()->clearUndoRedoStacks(); +} + +void ComposeWindow::refreshAttachmentList() +{ + m_attachmentList->clear(); + for (const QString &path : m_attachments) + m_attachmentList->addItem(QFileInfo(path).fileName()); + m_attachmentList->setVisible(!m_attachments.isEmpty()); +} + +bool ComposeWindow::attachmentNeedsWarning(qint64 size) const +{ + const qint64 limit = m_config.compose().attachmentWarnBytes; + // A limit of zero or less disables the warning outright. Treating it as a + // threshold would warn about every attachment including an empty one, + // which is the opposite of what turning a warning off means. + return limit > 0 && size > limit; +} + +/// A byte count as a figure a person reads, with one decimal below 10 units. +/// +/// Integer MB division is what this replaces and it produced "'x' is 0 MB. +/// Many mail servers refuse messages above about 0 MB.", which is what any +/// attachment_warn_bytes under a megabyte reads as. The unit steps down as +/// well, so a small configured limit is stated in KB rather than as zero of a +/// larger unit. +QString ComposeWindow::humanSize(qint64 bytes) +{ + constexpr qint64 kKb = 1024; + constexpr qint64 kMb = 1024 * 1024; + + if (bytes >= kMb) { + const double mb = double(bytes) / double(kMb); + // One decimal only while the figure is small enough for it to say + // something; 26.2 MB is informative, 1234.6 MB is noise. + return mb < 10.0 ? QObject::tr("%1 MB").arg(mb, 0, 'f', 1) + : QObject::tr("%1 MB").arg(qRound(mb)); + } + if (bytes >= kKb) { + const double kb = double(bytes) / double(kKb); + return kb < 10.0 ? QObject::tr("%1 KB").arg(kb, 0, 'f', 1) + : QObject::tr("%1 KB").arg(qRound(kb)); + } + return QObject::tr("%1 bytes").arg(bytes); +} + +void ComposeWindow::attachFile(const QString &path) +{ + const QFileInfo info(path); + + if (attachmentNeedsWarning(info.size())) { + const qint64 limit = m_config.compose().attachmentWarnBytes; + const auto answer = QMessageBox::question( + this, tr("Large attachment"), + tr("'%1' is %2. Many mail servers refuse messages above about " + "%3. Attach it anyway?") + .arg(info.fileName(), humanSize(info.size()), + humanSize(limit)), + QMessageBox::Yes | QMessageBox::No); + if (answer != QMessageBox::Yes) + return; + } + + m_attachments.append(path); + refreshAttachmentList(); + markDirty(); +} + +OutgoingMessage ComposeWindow::currentMessage() const +{ + OutgoingMessage message; + message.accountKey = currentAccount().key; + message.to = splitRecipients(m_to->text()); + message.cc = splitRecipients(m_cc->text()); + message.bcc = splitRecipients(m_bcc->text()); + message.subject = m_subject->text(); + message.markdownBody = m_body->toPlainText(); + message.sendHtml = m_sendHtml->isChecked(); + message.attachments = m_attachments; + message.inReplyTo = m_context.inReplyTo; + message.references = m_context.references; + return message; +} + +void ComposeWindow::applyEdit(const MarkdownFormat::Edit &edit) +{ + // A QTextCursor replacement rather than setPlainText(), and this is a + // correction of the plan's draft. Measured under the offscreen platform: + // setPlainText() DESTROYS the document's undo stack (isUndoAvailable goes + // from true to false) and resets the cursor to position 0, so every + // toolbar press would throw away everything the user could undo. A + // document-wide select and insertText inside one edit block leaves undo + // available, collapses to a SINGLE undo step, and emits textChanged once. + QTextCursor cursor = m_body->textCursor(); + cursor.beginEditBlock(); + cursor.select(QTextCursor::Document); + cursor.insertText(edit.text); + cursor.endEditBlock(); + + // Restore the selection the transformation asked for. The cursor is left + // at the end of the inserted text, so without this every button press + // sends it to the bottom of the message; the empty-selection case relies + // on it to land BETWEEN the tokens, which is the property a user notices + // immediately when it is wrong. + // + // Clamped rather than trusted: QTextCursor::setPosition() past the end + // warns on stderr and silently clamps, so a stale or arithmetic position + // would produce noise rather than an error. MarkdownFormat clamps its own + // output too, so this is a second line rather than the only one. + const int length = m_body->toPlainText().length(); + const int start = qBound(0, edit.selectionStart, length); + const int end = qBound(start, edit.selectionEnd, length); + + QTextCursor restored = m_body->textCursor(); + restored.setPosition(start); + restored.setPosition(end, QTextCursor::KeepAnchor); + m_body->setTextCursor(restored); + m_body->setFocus(); +} + +void ComposeWindow::applyFormat(const QString &token) +{ + const QTextCursor cursor = m_body->textCursor(); + applyEdit(MarkdownFormat::wrap(m_body->toPlainText(), + cursor.selectionStart(), + cursor.selectionEnd(), token)); +} + +void ComposeWindow::markDirty() +{ + m_dirty = true; + // Debounced: the timer restarts on every keystroke, so a write happens + // once the user has paused, not once per character. Every autosave + // produces a Maildir write that mbsync uploads, which is what the debounce + // and the dirty check together keep to a few revisions per message. + m_autosaveTimer->start(); +} + +void ComposeWindow::autosave() +{ + if (!m_dirty) + return; + saveDraftNow(); +} + +bool ComposeWindow::saveDraftNow() +{ + const Account account = currentAccount(); + if (account.drafts.isEmpty()) { + // Configured without a drafts folder. Warned about at startup; there + // is nothing to do here and nothing to report a second time. Reported + // as success because nothing failed: a false here would make the quit + // path offer a retry that cannot change anything. + return true; + } + + const OutgoingMessage message = currentMessage(); + + // The dirty CHECK, not just the flag: an unchanged message means no file + // is written and no sync is provoked. Every autosave produces a Maildir + // write that mbsync uploads, so this and the debounce together are what + // keep a message to a few revisions rather than dozens. + // + // Checked BEFORE the build, and on the message rather than on the bytes. + // The plan's draft compared built.bytes, which can never match: GMime is + // given a fresh Date and Message-ID on every build, so two builds of an + // unchanged message differ. That check would have read as working while + // writing a file on every debounce. Doing it first also skips the + // blocking build entirely for the no-change case, which is the common one. + const QString fingerprint = fingerprintOf(message); + if (!m_savedFingerprint.isEmpty() && fingerprint == m_savedFingerprint) { + m_dirty = false; + return true; + } + + // MessageBuilder::build() is SYNCHRONOUS and can block: a large attachment + // is read and base64-encoded on this thread, which is the GUI thread. A + // debounce firing with a 25MB attachment therefore stalls typing for as + // long as the read takes. Deliberately not moved to a thread: nothing here + // crosses the worker boundary, and a second threading model for one call + // is worse than the stall. If someone is measuring a composer freeze, this + // line is where to look. + const MessageBuilder::Result built = MessageBuilder::build(message, account); + if (!built.ok()) { + m_saveFailed = true; + m_banner->setText(tr("The draft could not be saved: %1").arg(built.error)); + m_banner->show(); + return false; + } + + const QString folder = QDir(m_mailRoot).absoluteFilePath( + account.maildir + QLatin1Char('/') + account.drafts); + + const DraftStore::Result written = + DraftStore::write(folder, built.bytes, QStringLiteral("D"), m_draftPath); + + if (!written.ok()) { + // A PERSISTENT banner, not a modal and not a status-bar line that + // fades. A modal mid-sentence is hostile while the user is typing, but + // the warning must survive until it is dealt with, because the quit + // path's honesty depends on it. + m_saveFailed = true; + m_banner->setText( + tr("The draft could not be saved: %1").arg(written.error)); + m_banner->show(); + return false; + } + + m_draftPath = written.path; + m_savedFingerprint = fingerprint; + m_dirty = false; + m_saveFailed = false; + m_banner->hide(); + return true; +} + +void ComposeWindow::setInputsEnabled(bool enabled) +{ + // Every input for the WHOLE operation, countdown included. The message + // must not change between the user pressing Send and the bytes being + // built. The send-failure pane is deliberately left alone: it is read-only + // and disabling it would make the stderr it carries unreadable. + m_to->setEnabled(enabled); + m_cc->setEnabled(enabled); + m_bcc->setEnabled(enabled); + m_subject->setEnabled(enabled); + m_from->setEnabled(enabled); + m_body->setReadOnly(!enabled); + m_sendHtml->setEnabled(enabled); + m_attachmentList->setEnabled(enabled); + m_formatToolbar->setEnabled(enabled); +} + +void ComposeWindow::showSendFailure(const QString &stderrText) +{ + m_sendLog->setPlainText(stderrText.isEmpty() + ? tr("The send command reported no output.") + : stderrText); + m_sendLogPane->show(); +} + +void ComposeWindow::send() +{ + // Refused outright while a send operation is up, countdown included. + // setInputsEnabled(false) disables the toolbar the Send action lives on + // and SendDialog is window-modal, so a user cannot reach this twice; the + // guard covers the programmatic route, where a second call would put a + // second dialog over the first and start a send MessageSender then + // refuses, leaving a popup with no result coming for it. + if (m_sendInFlight) + return; + m_sendInFlight = true; + + const Account account = currentAccount(); + + if (!account.canSend()) { + QMessageBox::warning( + this, tr("Cannot send"), + tr("The account '%1' has no send command configured.") + .arg(account.key)); + m_sendInFlight = false; + return; + } + + const MessageBuilder::Result built = + MessageBuilder::build(currentMessage(), account); + if (!built.ok()) { + // A missing attachment lands here, before anything runs. + QMessageBox::warning(this, tr("Cannot send"), built.error); + m_sendInFlight = false; + return; + } + + // Every input is disabled for the WHOLE operation, countdown included. + setInputsEnabled(false); + + auto *dialog = new SendDialog(m_config.compose().sendDelayMs, this); + + connect(dialog, &SendDialog::undone, this, [this, dialog]() { + // Nothing reached a server. The composer returns exactly as it was, + // editable, popup gone, nothing sent. + // + // deleteLater(), never delete: this runs SYNCHRONOUSLY inside + // SendDialog::undo(), which emits undone() and then calls reject() on + // itself (senddialog.cpp), so the dialog is still on the stack here. + // This is CLAUDE.md's "a modal dialog must close BEFORE the action it + // asked for runs" arriving from the other side, and deleteLater is + // what makes it safe: it posts a deletion event rather than freeing + // the object the caller is about to keep using. A plain delete here + // would return into a destroyed SendDialog's reject(). + m_sendInFlight = false; + setInputsEnabled(true); + dialog->deleteLater(); + }); + + connect(dialog, &SendDialog::committed, this, + [this, dialog, built, account]() { + // No setStage(Sending) here: SendDialog::commit() sets it before + // emitting committed(), so doing it again would be a second owner of + // the same state. + + // Qt::SingleShotConnection IS REQUIRED HERE. m_sender is a long-lived + // member, so a bare connect() beside each send() accumulates a + // permanent receiver per send. Send, fail, correct the recipient, send + // again, and the second result runs BOTH lambdas: the first still + // holds the FIRST message's `built` and `account` by value, so it + // files a sent copy of the wrong message and calls accept() on a + // dialog it already deleteLater()'d. MessageSender's m_reported guard + // cannot prevent this: it collapses two QProcess signals into one + // emit, and this is one emit reaching many receivers. Measured in + // test_messagesender.cpp::aPerSendConnectionMustBeSingleShot, where + // the bare shape delivers 3 results for 2 sends and the single-shot + // shape delivers 2. + const QMetaObject::Connection resultConnection = connect( + m_sender, &MessageSender::finished, this, + [this, dialog, built, account](bool sent, const QString &error) { + m_sendInFlight = false; + + if (!sent) { + dialog->accept(); + dialog->deleteLater(); + setInputsEnabled(true); + + // The draft stays, and it must be the draft of what was just + // attempted. send() builds from the widgets without saving, so + // the revision on disk is whatever the last debounce wrote: + // edit, send, fail, close, and the user gets the OLDER text + // back, having watched their correction be sent. No retry + // loop, but the text that failed to go is kept. + saveDraftNow(); + + showSendFailure(error); + return; + } + + dialog->setStage(SendDialog::Stage::FilingSentCopy); + bool sentCopyFailed = false; + QString sentCopyError; + + if (!account.sent.isEmpty()) { + const QString folder = QDir(m_mailRoot).absoluteFilePath( + account.maildir + QLatin1Char('/') + account.sent); + const DraftStore::Result filed = + DraftStore::write(folder, built.bytes, QStringLiteral("S")); + if (!filed.ok()) { + sentCopyFailed = true; + sentCopyError = filed.error; + } + } + + dialog->setStage(SendDialog::Stage::RemovingDraft); + if (!m_draftPath.isEmpty()) { + QFile::remove(m_draftPath); + m_draftPath.clear(); + } + + dialog->accept(); + dialog->deleteLater(); + + if (sentCopyFailed) { + // A MODAL, never a status-bar line, and never reported as a + // send failure. The message went; reporting otherwise makes + // someone send it twice. This is the one failure in the whole + // design that produces a silent divergence between what the + // recipient received and what the local archive shows, and + // nobody discovers a missing sent copy by noticing a line that + // appeared for a few seconds. + QMessageBox::warning( + this, tr("Sent, but not filed"), + tr("The message was sent, but the copy could not be " + "written to '%1' for account '%2':\n\n%3\n\n" + "The message HAS been sent. Do not send it again.") + .arg(account.sent, account.key, sentCopyError)); + } + + // The composer closes either way: the message went, and holding a + // composer open for a message already sent invites sending it + // twice. m_finished stops closeEvent() saving a draft for a + // message that is gone, and stops it refusing the close. + m_finished = true; + m_dirty = false; + close(); + }, Qt::SingleShotConnection); + + if (!m_sender->send(account.sendCommand, built.bytes)) { + // Refused before any process started, so no finished() will ever + // arrive and the single-shot connection above would sit there for + // good. Disconnected here rather than left, since the next send + // would then have two receivers, which is exactly the defect the + // flag exists to prevent. + // + // THE HANDLE, not disconnect(m_sender, &finished, this, nullptr). + // That form drops every finished receiver on this object, so one + // connection added anywhere else would be killed here silently, + // and the failure it produces is not a wrong value but silence: a + // send whose result nobody processes, leaving the popup on + // "Sending...", the composer disabled, and no error anywhere. + // + // UNTESTED, and deliberately so rather than by omission. This + // branch is currently UNREACHABLE: MessageSender::send() returns + // false only for an empty command or a command already running, + // and canSend() rejects the first while m_sendInFlight rejects the + // second before either can arrive here. QSettings also unquotes + // every INI value, so no configured string survives trimming yet + // splits to nothing. A test would have to reach past the public + // surface to provoke it, and a test that cannot fail is worse than + // none. Kept because it costs nothing and stops being dead the + // moment send() grows a third refusal, which is the shape an + // outbox drain loop would add. + m_sendInFlight = false; + disconnect(resultConnection); + dialog->accept(); + dialog->deleteLater(); + setInputsEnabled(true); + showSendFailure(tr("The send command could not be started.")); + } + }); + + dialog->open(); +} + +void ComposeWindow::closeEvent(QCloseEvent *event) +{ + // Refused for the WHOLE send, countdown included, and the countdown half + // is the one easily lost. A guard that starts at commit leaves the five + // seconds before it unprotected: closing then destroys this window, takes + // the parented SendDialog down with it, and committed() never fires, so + // the user pressed Send, watched a countdown, and believes the mail went. + // After commit the reason is the one MessageSender's destructor + // documents: a live SMTP conversation abandoned is an outcome nobody can + // report truthfully. + // + // Both windows close themselves when the operation ends, so refusing here + // strands nothing. + if (m_sendInFlight && !m_finished) { + event->ignore(); + return; + } + + // The last-moment autosave, and the reason it is here rather than in the + // quit path: the debounce means a composer closed inside its interval has + // unwritten text, and WA_DeleteOnClose destroys the window immediately + // after this. Without this call, typing a paragraph and pressing the + // window manager's X inside thirty seconds loses it silently, with no + // prompt and no write, which is exactly the loss the autosave design + // exists to prevent. + // + // Its failure is deliberately NOT allowed to refuse the close. A window + // that will not close because it cannot save is worse than one that closes + // having said so: the banner is already up from saveDraftNow(), and the + // quit path reads lastSaveFailed() to escalate. Task 12 owns that dialog; + // this call is what makes there be something to escalate ABOUT. + if (m_dirty && !m_finished) + saveDraftNow(); + + emit closed(this); + QMainWindow::closeEvent(event); +} diff --git a/src/composewindow.h b/src/composewindow.h new file mode 100644 index 0000000..af50be6 --- /dev/null +++ b/src/composewindow.h @@ -0,0 +1,267 @@ +/* + * 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 <QMainWindow> +#include <QStringList> + +#include <memory> + +#include "config.h" +#include "formattoolbar.h" // MarkdownFormat::Edit is used by value below, and + // a type nested in a namespace cannot be + // forward-declared from outside it. +#include "types.h" + +class QAction; +class QCheckBox; +class QComboBox; +class QLabel; +class QLineEdit; +class QListWidget; +class QPlainTextEdit; +class QTimer; +class QToolBar; +class QTemporaryDir; +class QWidget; + +class MessageSender; + +/// One draft. A separate top-level window, several open at once. +/// +/// A QMainWindow rather than a dialog: a modal dialog cannot consult another +/// message while writing, which is most of what replying is, and taking over +/// the message pane fights the pane that exists to show what is being replied +/// to. +/// +/// NO GEOMETRY RESTORE and no geometry save. CLAUDE.md records what +/// saveGeometry does under a tiling compositor: it stores normalGeometry, the +/// compositor owns the tile, and the restore is correct while looking broken. +/// A whole session went into that once. The composer opens at a sensible +/// default size and the compositor places it. +/// +/// It contains no MIME and no process logic: a composer bug and a MIME bug are +/// found in different files. Everything it does with a message goes through +/// MessageBuilder, DraftStore, MessageSender, MarkdownFormat and SendDialog. +class ComposeWindow : public QMainWindow +{ + Q_OBJECT + +public: + /// \p mailRoot is the Maildir root, passed in rather than derived. + /// + /// There is NO Config::maildirPath(). The root comes from + /// notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT), wrapped by mailRootOf() + /// which is file-static inside notmuchworker.cpp and needs the database + /// handle. Item 124 records why this matters: notmuch can split the index + /// from the mail, and under that layout database.path is the INDEX + /// directory. Composing a destination from the wrong root would write + /// drafts and sent copies into the Xapian tree. MainWindow already + /// receives the root from the worker; it passes it here. + ComposeWindow(const ComposeContext &context, const Config &config, + const QString &mailRoot, QWidget *parent = nullptr); + + /// Defined in the .cpp, not defaulted here. m_forwardedParts is a + /// unique_ptr to a forward-declared QTemporaryDir, whose deleter needs the + /// complete type; an implicit destructor would be generated here, where it + /// is still incomplete. + ~ComposeWindow() override; + + /// True when the buffer has changed since the last successful autosave. + /// The quit path asks every open composer this. + bool hasUnsavedEdits() const { return m_dirty; } + + /// True when the LAST autosave attempt failed. Escalated to its own + /// dialog on the way out, because saving is what is already not working + /// and quitting therefore loses that text. + bool lastSaveFailed() const { return m_saveFailed; } + + /// Writes the current buffer to the drafts folder now. Returns false and + /// leaves the banner up on failure. + /// + /// Returns TRUE when the account configures no drafts folder: nothing was + /// written and nothing failed, and reporting a failure would make the quit + /// path offer a retry for a state no retry can change. The composer + /// running without draft protection is warned about at startup instead. + bool saveDraftNow(); + + /// What the composer would send or save right now. + /// + /// Public so a test can assert on the message the widgets produce without + /// building MIME, and so the quit path can be reasoned about from values. + OutgoingMessage currentMessage() const; + + /// The paths currently attached, in the order they were attached. + QStringList attachments() const { return m_attachments; } + + /// Attaches \p path, asking first when it is larger than + /// [compose] attachment_warn_bytes. + /// + /// A warning rather than a refusal: the limit belongs to the recipient's + /// server, which this application cannot know, so the user decides. + void attachFile(const QString &path); + + /// A byte count as a figure a person reads. + /// + /// Static and public so the formatting is testable without a modal. The + /// integer MB division this replaces produced "0 MB" for any + /// attachment_warn_bytes under a megabyte, in both halves of the same + /// sentence. + static QString humanSize(qint64 bytes); + + /// Whether \p size would raise the large-attachment question. + /// + /// Split out so the threshold is testable without a modal. A limit of zero + /// or less disables the warning outright rather than warning about + /// everything. + bool attachmentNeedsWarning(qint64 size) const; + +signals: + /// The composer finished with its message, one way or another, and the + /// registry should forget it. + /// + /// Emitted from the close path, so a registry connected to it can drop its + /// pointer before WA_DeleteOnClose destroys the window. + void closed(ComposeWindow *window); + +protected: + /// The one place the registry is told, whichever route closes the window. + void closeEvent(QCloseEvent *event) override; + +private: + void buildUi(); + void buildFormatToolbar(); + void seedFields(); + + /// Extracts a forwarded message's parts into m_forwardedParts and appends + /// their paths to m_attachments. + /// + /// The spec requires Forward to carry attachments, and they have to become + /// FILES because MessageBuilder reads every attachment by path. Extraction + /// happens here rather than in MainWindow so the files and the directory + /// that owns them are created together and die together. + /// + /// A part that cannot be written is SKIPPED with a banner rather than + /// failing the forward: some of the attachments is better than none, and + /// MessageBuilder refuses a build naming any path that later vanishes, so + /// a silently wrong send is not among the outcomes. + void extractForwardedAttachments(); + void seedBody(); + void refreshAttachmentList(); + void setInputsEnabled(bool enabled); + void showSendFailure(const QString &stderrText); + void applyEdit(const MarkdownFormat::Edit &edit); + void markDirty(); + void autosave(); + void send(); + void applyFormat(const QString &token); + Account currentAccount() const; + + ComposeContext m_context; + Config m_config; + QString m_mailRoot; + QStringList m_attachments; + + /// Holds the parts a Forward extracted, for exactly as long as this window. + /// + /// Owned HERE rather than by MainWindow, because the lifetime that makes + /// sense is the composer's: MessageBuilder reads every attachment by PATH + /// at build time (messagebuilder.cpp:212), on each autosave and again at + /// send, so the files must outlive every build this window performs and + /// nothing after it. QTemporaryDir's destructor removes the tree, so + /// closing without sending cleans up rather than leaking. + /// + /// A draft does not depend on it. Autosave writes a COMPLETE MIME message + /// with the bytes embedded, so a saved draft stays valid after these files + /// are gone; and DraftStore is write-only, with no reopen path anywhere in + /// this codebase, so the "reopened next session pointing at a dead temp + /// path" hazard cannot arise. Should a reopen path ever be added, it must + /// read attachments back out of the draft's own MIME rather than trusting + /// a stored path. + /// + /// Null unless a Forward actually extracted something. unique_ptr because + /// QTemporaryDir is neither copyable nor movable. + std::unique_ptr<QTemporaryDir> m_forwardedParts; + + QLineEdit *m_to = nullptr; + QLineEdit *m_cc = nullptr; + QLineEdit *m_bcc = nullptr; + QLineEdit *m_subject = nullptr; + QComboBox *m_from = nullptr; + QPlainTextEdit *m_body = nullptr; + QCheckBox *m_sendHtml = nullptr; + QLabel *m_banner = nullptr; + QListWidget *m_attachmentList = nullptr; + QWidget *m_sendLogPane = nullptr; + QPlainTextEdit *m_sendLog = nullptr; + QToolBar *m_formatToolbar = nullptr; + QAction *m_sendAction = nullptr; + QAction *m_attachAction = nullptr; + QAction *m_detachAction = nullptr; + + QTimer *m_autosaveTimer = nullptr; + MessageSender *m_sender = nullptr; + + QString m_draftPath; ///< The revision on disk, unlinked on the next write. + + /// A fingerprint of the message the last successful save wrote, for the + /// dirty CHECK. + /// + /// NOT the built bytes, and that is a correction of the plan's draft. + /// MessageBuilder generates a fresh Date and Message-ID on every build + /// (measured, messagebuilder.cpp around the g_mime_message_set_date call), + /// so two builds of an unchanged message never compare equal and a check + /// on the bytes can never fire. It would read as working while writing a + /// file, and an mbsync upload, on every debounce. + QString m_savedFingerprint; + bool m_dirty = false; + bool m_saveFailed = false; + + /// True from the moment Send is pressed until the operation ends, however + /// it ends: the countdown, the command, the sent copy. + /// + /// ONE flag, covering the whole operation, and an earlier revision had two + /// because a narrower "committed and running" flag reads as the honest + /// thing to guard a live SMTP conversation with. It is not: every question + /// this window has to answer while sending has the same answer through the + /// countdown as after it. A close during the countdown destroys the + /// parented SendDialog and committed() never fires, so the user watches a + /// countdown for a message that is never sent, and a second Send during + /// the countdown opens a second popup. Splitting the two left the narrower + /// flag written in three places and read in none. + bool m_sendInFlight = false; + + /// Set once the message has gone, so the close that follows a successful + /// send is neither refused nor made to write a draft. + /// + /// The close-REFUSAL half is load-bearing: m_sendInFlight is cleared in + /// the same handler, and without m_finished the composer's own close would + /// depend on that clear having already happened, which is a race rather + /// than a guarantee. + /// + /// The last-moment-SAVE half is deliberately redundant, and it is worth + /// saying so rather than letting the next reader mistake it for load + /// bearing: the send handler already clears m_dirty, so either condition + /// alone stops the save. Measured, each survives the other's removal and + /// only dropping both puts the draft of an already-sent message back on + /// disk. Kept because the two say different things, "nothing to write" and + /// "this window is done", and a future path that finishes without clearing + /// m_dirty would otherwise resurrect a sent message's draft silently. + bool m_finished = false; +}; diff --git a/src/config.cpp b/src/config.cpp index a2d1cec..c6bedd2 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -23,6 +23,8 @@ // so this reports the same numbers rather than keeping a second copy. #include "messageview.h" +#include <algorithm> + #include <QDateTime> #include <QDir> #include <QFile> @@ -469,6 +471,12 @@ void Config::load(const QString &path) account.inbox = settings.value(QStringLiteral("inbox")).toString().trimmed(); + // Optional, and its absence IS the receive-only state: see the field + // comment in config.h. Run without a shell, so trimming here is only + // whitespace hygiene, never a quoting concern. + account.sendCommand = + settings.value(QStringLiteral("send_command")).toString().trimmed(); + // Both optional, and both describe this account's chip in the thread // list. An account tag is a different taxonomy from a functional one, // saying which mailbox a thread arrived in rather than what state it @@ -516,6 +524,93 @@ void Config::load(const QString &path) m_accounts.append(account); } + settings.beginGroup(QStringLiteral("compose")); + // Absent keys stay silent (the struct's own default holds), but a + // PRESENT and malformed value is reported: value(key, default) alone + // would happily accept "quote_position = abov" as Above, matching every + // other enum-ish key in this file (sync_on_exit, language, date_format) + // rather than being the one silent exception. + const QString quotePosition = + settings.value(QStringLiteral("quote_position"), QStringLiteral("above")) + .toString().trimmed(); + if (quotePosition.compare(QStringLiteral("above"), Qt::CaseInsensitive) == 0) { + m_compose.quotePosition = ComposeSettings::QuotePosition::Above; + } else if (quotePosition.compare(QStringLiteral("below"), Qt::CaseInsensitive) == 0) { + m_compose.quotePosition = ComposeSettings::QuotePosition::Below; + } else { + addProblem(tr("[compose] quote_position '%1' is not recognised; " + "expected above or below. Using above.") + .arg(quotePosition)); + } + + m_compose.sendHtml = + settings.value(QStringLiteral("send_html"), true).toBool(); + + // 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 + // toInt()/toLongLong(), and a reported fallback to the struct's own + // default on failure. The bare toInt()/toLongLong() this replaced return + // 0 on a PARSE FAILURE, not the default, which is silently indistinguishable + // from the user writing 0 on purpose. For autosave_interval_ms that 0 + // reaches a QTimer restarted on every keystroke, so it would fire on the + // very next event-loop pass and turn the debounce into a write per + // keystroke, each one uploaded by mbsync. + const QVariant autosave = settings.value(QStringLiteral("autosave_interval_ms")); + if (autosave.isValid()) { + bool ok = false; + const int value = autosave.toString().trimmed().toInt(&ok); + if (ok) { + // Clamped, not merely parsed: nothing in the spec assigns a + // meaning to a zero or negative autosave interval, unlike + // mark_read_delay_ms where negative-means-off is documented + // behaviour. A zero interval here is the same runaway-write + // hazard as the parse failure above, just spelled correctly. + m_compose.autosaveIntervalMs = qMax(1000, value); + } else { + addProblem(tr("[compose] autosave_interval_ms '%1' is not a " + "number; using %2.") + .arg(autosave.toString()) + .arg(m_compose.autosaveIntervalMs)); + } + } + + // Zero is a REAL setting here, meaning "send at once", and must be + // honoured rather than mistaken for unset: that is exactly why this is + // isValid()-then-checked-parse rather than a zero-test. + const QVariant sendDelay = settings.value(QStringLiteral("send_delay_ms")); + if (sendDelay.isValid()) { + bool ok = false; + const int value = sendDelay.toString().trimmed().toInt(&ok); + if (ok) { + m_compose.sendDelayMs = value; + } else { + addProblem(tr("[compose] send_delay_ms '%1' is not a number; " + "using %2.") + .arg(sendDelay.toString()) + .arg(m_compose.sendDelayMs)); + } + } + + m_compose.defaultAccount = + settings.value(QStringLiteral("default_account")).toString().trimmed(); + + const QVariant attachmentWarn = + settings.value(QStringLiteral("attachment_warn_bytes")); + if (attachmentWarn.isValid()) { + bool ok = false; + const qint64 value = attachmentWarn.toString().trimmed().toLongLong(&ok); + if (ok) { + m_compose.attachmentWarnBytes = value; + } else { + addProblem(tr("[compose] attachment_warn_bytes '%1' is not a " + "number; using %2.") + .arg(attachmentWarn.toString()) + .arg(m_compose.attachmentWarnBytes)); + } + } + settings.endGroup(); + loadSavedQueries(path, settings); // Checked here rather than where startup_query is read: the saved queries @@ -534,6 +629,61 @@ void Config::load(const QString &path) m_startupAccount.clear(); } + // default_account is validated here, once the accounts are parsed. A + // named account that cannot send is reported: the user named an account + // and expects mail to come from it, unlike an installation where no + // account can send at all, which is a valid read-only setup and not + // warned about below. + // + // Unlike startup_account just above, the bad value is NOT cleared after + // the warning: the composer resolves this through canSend() at the point + // of use, so a value naming an unusable account is simply skipped there + // rather than needing to be blanked here. + if (!m_compose.defaultAccount.isEmpty()) { + const auto named = std::find_if( + m_accounts.cbegin(), m_accounts.cend(), + [this](const Account &a) { return a.key == m_compose.defaultAccount; }); + + if (named == m_accounts.cend()) { + addProblem( + tr("[compose] default_account names '%1', which is not a " + "configured account. A new message will pick a sending " + "account by the usual rules.") + .arg(m_compose.defaultAccount)); + } else if (!named->canSend()) { + addProblem( + tr("[compose] default_account names '%1', which has no " + "send_command and cannot send. A new message will pick a " + "sending account by the usual rules.") + .arg(m_compose.defaultAccount)); + } + } + + for (const Account &account : m_accounts) { + if (!account.canSend()) + continue; + // A notice, not a problem: a provider whose SMTP server files sent + // mail on its own is a legitimate, permanently correct configuration. + // addProblem() here would raise a startup modal on every launch for a + // setup that will never change, which is exactly how a user learns to + // dismiss dialogs unread. + if (account.sent.isEmpty()) { + addNotice( + tr("Account '%1' can send but configures no `sent` folder, so " + "no local copy of sent mail is filed.") + .arg(account.key)); + } + // Still a problem: unlike a missing sent folder, this is a real loss + // of protection (no draft is saved while composing) rather than a + // deliberate provider-side choice. + if (account.drafts.isEmpty()) { + addProblem( + tr("Account '%1' can send but configures no `drafts` folder, " + "so the composer runs without draft protection.") + .arg(account.key)); + } + } + // Asks whether the resolved query matched on EITHER a name or a generator, // rather than comparing the name alone. Comparing names warned about a // config that was working: `startup_query = Inbox` resolves through the @@ -948,6 +1098,16 @@ SavedQuery Config::startupSavedQuery() const return builtinFilter(QStringLiteral("unread")); } +QList<Account> Config::sendingAccounts() const +{ + QList<Account> sending; + for (const Account &account : m_accounts) { + if (account.canSend()) + sending.append(account); + } + return sending; +} + Account Config::account(const QString &key) const { for (const Account &a : m_accounts) { diff --git a/src/config.h b/src/config.h index ede5dea..51fc1ee 100644 --- a/src/config.h +++ b/src/config.h @@ -67,6 +67,24 @@ struct Account /// reports a missing key through the warnings path. QString trash; + /// The command that sends mail from this account, receiving the complete + /// RFC822 message on stdin. Optional, and its ABSENCE is meaningful: + /// an account without one is receive-only by construction. + /// + /// Not a separate `receive_only` key. The capability IS this command's + /// presence, so there is nothing to keep in step and nothing to + /// contradict. One real account is receive-only on purpose and gains no + /// configuration at all, which is the point. + /// + /// Split with QProcess::splitCommand and run WITHOUT a shell, exactly as + /// [sync] command is, so nothing in a message body, a recipient address or + /// a display name can reach sh. No message content is ever placed in an + /// argument: recipients come from the message's own headers. + QString sendCommand; + + /// Whether this account can send at all. + bool canSend() const { return !sendCommand.isEmpty(); } + /// The account's inbox folder, relative to maildir. Optional. /// /// Only Restore reads it, as the destination for a message that carries no @@ -186,6 +204,35 @@ struct SavedQuery QJsonObject unknown; }; +/// The [compose] section. Every key is optional with the default shown. +struct ComposeSettings +{ + /// Where the quote goes in a reply. Whether to quote AT ALL is not here: + /// that is decided by which action was invoked (reply quotes, + /// reply_no_quote does not). + enum class QuotePosition { Above, Below }; + + QuotePosition quotePosition = QuotePosition::Above; + + /// Seeds the per-message toggle for New and Forward only. Reply and + /// Reply-all seed from whether the original carried a text/html part, + /// ignoring this value: an HTML part in the original is a fact about the + /// sender's software, not a guess about their taste. + bool sendHtml = true; + + int autosaveIntervalMs = 30000; + + /// The undo window before sending. Zero skips the countdown entirely and + /// sends at once, for anyone who finds it irritating. + int sendDelayMs = 5000; + + /// Preferred account for a New message when the dropdown is on All + /// accounts. Falls through when it names an account that cannot send. + QString defaultAccount; + + qint64 attachmentWarnBytes = 26214400; +}; + /// Reads ~/.config/qtmaildir/qtmaildir.conf. /// /// The Maildir path is deliberately NOT configurable here: notmuch already @@ -207,6 +254,14 @@ public: QList<Account> accounts() const { return m_accounts; } Account account(const QString &key) const; + ComposeSettings compose() const { return m_compose; } + + /// Every account with a send_command, in configuration order. + /// + /// Empty is a valid read-only installation, NOT a misconfiguration: the + /// compose actions are simply disabled and nothing is warned about. + QList<Account> sendingAccounts() const; + /// In document order, which IS the display order. Never sort this. QList<SavedQuery> savedQueries() const { return m_savedQueries; } void setSavedQueries(const QList<SavedQuery> &queries) @@ -443,6 +498,7 @@ private: QList<Account> m_accounts; QList<SavedQuery> m_savedQueries; + ComposeSettings m_compose; /// Where saveSavedQueries() writes, remembered from load(). QString m_queriesPath; diff --git a/src/draftstore.cpp b/src/draftstore.cpp new file mode 100644 index 0000000..d458bff --- /dev/null +++ b/src/draftstore.cpp @@ -0,0 +1,82 @@ +/* + * 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 "draftstore.h" + +#include "maildirname.h" + +#include <QDir> +#include <QFile> +#include <QObject> +#include <QSaveFile> + +DraftStore::Result DraftStore::write(const QString &folderPath, + const QByteArray &bytes, + const QString &flags, + const QString &previousPath) +{ + Result result; + + if (folderPath.isEmpty()) { + result.error = QObject::tr("No folder was configured to write to."); + return result; + } + + // cur/, never new/. A file in new/ is re-announced as fresh mail by every + // reader of the Maildir, so an autosaved draft would arrive as a new + // message on every revision. + const QString curPath = folderPath + QStringLiteral("/cur"); + if (!QDir().mkpath(curPath)) { + result.error = QObject::tr("Cannot create the folder %1.").arg(curPath); + return result; + } + + // A FRESH name, with no previous one to preserve flags from: a draft is + // newly composed, and MessageBuilder's bytes carry no filename. The flags + // are appended here instead. + const QString name = MaildirName::fresh(QString()) + + QStringLiteral(":2,") + flags; + const QString target = curPath + QLatin1Char('/') + name; + + // QSaveFile: writes to a temporary and renames into place, so a reader + // never sees a half-written message. mbsync and notmuch both watch this + // directory. + QSaveFile file(target); + if (!file.open(QIODevice::WriteOnly)) { + result.error = QObject::tr("Cannot write to %1: %2") + .arg(target, file.errorString()); + return result; + } + + if (file.write(bytes) != bytes.size() || !file.commit()) { + result.error = QObject::tr("Cannot write to %1: %2") + .arg(target, file.errorString()); + return result; + } + + result.path = target; + + // AFTER the new file is safely in place, never before: unlinking first + // would lose the draft entirely if the write then failed. A failure to + // remove the old revision is not reported as a failure of the write, + // because the new revision IS on disk; the cost is one stale file. + if (!previousPath.isEmpty() && previousPath != target) + QFile::remove(previousPath); + + return result; +} diff --git a/src/draftstore.h b/src/draftstore.h new file mode 100644 index 0000000..13147c7 --- /dev/null +++ b/src/draftstore.h @@ -0,0 +1,60 @@ +/* + * 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 <QByteArray> +#include <QString> + +/// Writes message bytes into a Maildir folder. +/// +/// Drafts and sent copies are the same operation into different folders with +/// different flags, so they are one unit. Nothing here calls notmuch: the +/// files become visible on the next sync, which keeps the read-only-by-default +/// rule intact and needs no write lock. +class DraftStore +{ +public: + struct Result + { + QString path; ///< The file written. Empty on failure. + QString error; ///< Empty on success. + + bool ok() const { return error.isEmpty(); } + }; + + /// Writes \p bytes into \p folderPath, an absolute Maildir folder. + /// + /// The folder is the CALLER's to resolve, and item 124 is why it is not + /// resolved here: the mail root comes from + /// `notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT)`, never from + /// `notmuch_database_get_path()`, which under a split index returns the + /// Xapian directory. A store that composed its own path from the wrong + /// accessor would write drafts into the index tree. + /// + /// \p flags is the Maildir flag string without the `:2,` prefix: "D" for a + /// draft, "S" for a sent copy. + /// + /// \p previousPath, when not empty, is unlinked AFTER the new file is + /// safely in place. Maildir has no in-place edit, so a draft rewritten + /// every thirty seconds would otherwise accumulate one file per pause. + /// The order matters: unlinking first would lose the draft entirely if the + /// write then failed. + static Result write(const QString &folderPath, const QByteArray &bytes, + const QString &flags, const QString &previousPath = {}); +}; diff --git a/src/formattoolbar.cpp b/src/formattoolbar.cpp new file mode 100644 index 0000000..565d4af --- /dev/null +++ b/src/formattoolbar.cpp @@ -0,0 +1,182 @@ +/* + * 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 "formattoolbar.h" + +#include <QStringList> + + +namespace { + +/// Normalises the selection a widget reports into an ordered, in-range pair +/// that does not split a character. +/// +/// Three hazards, handled once rather than per function. A backwards drag +/// reports the anchor AFTER the cursor; a selection can outlive the edit that +/// shortened the buffer under it; and a boundary can land in the middle of a +/// surrogate pair, where inserting a token splits one character into two +/// halves and the result is not valid UTF-16 at all. +/// +/// The surrogate case is not reachable with an arrow key or the mouse, which +/// move in whole clusters, but QTextCursor::setPosition accepts such a +/// position, so any caller computing one arithmetically can produce it: a +/// draft restore, a find/replace, a template insertion. A boundary sitting on +/// a LOW surrogate is inside a pair, and moving it back by one puts it before +/// the whole character. +/// +/// A COLLAPSED cursor moves back, not outward: nudging the two ends in +/// opposite directions would turn an empty selection into a two-unit one and +/// wrap a character the user never selected. A real selection widens, so that +/// touching any part of a character covers the whole of it. +void normalise(const QString &text, int &from, int &to) +{ + from = qBound(0, from, int(text.size())); + to = qBound(0, to, int(text.size())); + if (from > to) + qSwap(from, to); + + const auto insidePair = [&text](int at) { + return at < text.size() && text.at(at).isLowSurrogate(); + }; + + if (from == to) { + if (insidePair(from)) { + --from; + to = from; + } + return; + } + + if (insidePair(from)) + --from; + if (insidePair(to)) + ++to; +} + +} // namespace + +MarkdownFormat::Edit MarkdownFormat::wrap(const QString &text, int start, + int end, const QString &token) +{ + Edit edit; + int from = start; + int to = end; + normalise(text, from, to); + + edit.text = text; + // The closing token first: inserting at `from` would shift `to`. + edit.text.insert(to, token); + edit.text.insert(from, token); + + if (from == to) { + // No selection: the cursor goes BETWEEN the two tokens so typing + // continues inside them. Landing after the closing token instead is + // the mistake a user notices on the first keystroke. + edit.selectionStart = from + token.size(); + edit.selectionEnd = edit.selectionStart; + } else { + // The selection is preserved so a second press applies a second token + // to the same words without reselecting: bold then italic. + edit.selectionStart = from + token.size(); + edit.selectionEnd = to + token.size(); + } + + return edit; +} + +MarkdownFormat::Edit MarkdownFormat::link(const QString &text, int start, int end) +{ + Edit edit; + int from = start; + int to = end; + normalise(text, from, to); + + const QString label = text.mid(from, to - from); + + edit.text = text; + edit.text.replace(from, to - from, QStringLiteral("[%1]()").arg(label)); + + if (label.isEmpty()) { + // Nothing selected: the label is what gets typed first, so the cursor + // goes inside the brackets, one past the '['. + edit.selectionStart = from + 1; + } else { + // The label is written; the URL is what remains, so the cursor goes + // inside the parentheses: past '[', the label, ']' and '('. + edit.selectionStart = from + label.size() + 3; + } + edit.selectionEnd = edit.selectionStart; + + return edit; +} + +MarkdownFormat::Edit MarkdownFormat::quote(const QString &text, int start, int end) +{ + Edit edit; + int from = start; + int to = end; + normalise(text, from, to); + + // Line-based, not a wrap. The selection is widened to whole lines first: + // quoting half a line produces markdown that means something else. + // + // The backwards search starts at `from - 1`, not at `from`. QString's + // lastIndexOf INCLUDES the position it is given, so a cursor sitting at + // the end of a line, immediately before its newline, would find that + // newline and quote the FOLLOWING line instead of the one the cursor is + // on. The guard against a negative position matters too, since -1 means + // "search from the end" and would find the last newline in the buffer. + const int firstLineStart = + from > 0 ? text.lastIndexOf(QLatin1Char('\n'), from - 1) + 1 : 0; + + // No newline after the last line, so the end of the text is the end of + // the block. Without this the whole tail would be dropped. + int lastLineEnd = text.indexOf(QLatin1Char('\n'), to); + if (lastLineEnd < 0) + lastLineEnd = text.size(); + + const QString before = text.left(firstLineStart); + const QString middle = text.mid(firstLineStart, lastLineEnd - firstLineStart); + const QString after = text.mid(lastLineEnd); + + const QStringList lines = middle.split(QLatin1Char('\n')); + + // Nesting rather than toggling, per the spec: a second press deepens the + // quote. There is deliberately no live toggle here, because tracking "my + // text" and "the quote" as separate pieces to make one reversible is + // machinery for a case the user answers by closing the composer. + QStringList result; + result.reserve(lines.size()); + for (const QString &line : lines) { + // A blank line keeps the marker, since that is what continues a quote + // block in markdown, but WITHOUT the trailing space: several editors + // and mail clients strip trailing whitespace, and stripping it from + // "> " leaves ">" anyway, so writing it bare is the same result + // reached deliberately. + result.append(line.isEmpty() ? QStringLiteral(">") + : QStringLiteral("> ") + line); + } + + const QString replacement = result.join(QLatin1Char('\n')); + edit.text = before + replacement + after; + // The quoted block stays selected, so a second press nests it. + edit.selectionStart = firstLineStart; + edit.selectionEnd = firstLineStart + replacement.size(); + + return edit; +} diff --git a/src/formattoolbar.h b/src/formattoolbar.h new file mode 100644 index 0000000..d820a88 --- /dev/null +++ b/src/formattoolbar.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> + +/// The markdown transformations behind the composer's formatting toolbar. +/// +/// Free functions over text and a selection, with no widget anywhere, so the +/// grammar is tested without a painter. Each one is a transformation over the +/// SOURCE: nothing about the buffer changes, it stays markdown the user can +/// also type by hand. +/// +/// Every function takes the selection as the widget reports it, which means +/// the anchor may sit AFTER the cursor. Each one normalises with qMin/qMax +/// rather than requiring the caller to, since a backwards drag is an ordinary +/// gesture and a caller that forgets would corrupt the buffer silently. +/// Out-of-range positions are clamped to the text, so a stale selection +/// cannot index past the end, and a boundary landing INSIDE a surrogate pair +/// is nudged off it, so a position computed arithmetically cannot split a +/// character in half. +/// +/// Neither wrap() nor quote() TOGGLES. A second press stacks another level: +/// `**this**` becomes `***this***` and `> one` becomes `> > one`. That is the +/// design, not an omission. The selection is preserved precisely so a second +/// press can apply a SECOND token to the same words, bold then italic without +/// reselecting, and a toggle would make that gesture unreachable. A toggle is +/// wanted eventually and is a spec change rather than a fix; see item 135 in +/// the backlog for the states it has to distinguish. +namespace MarkdownFormat { + +/// The result of a transformation: the new text and where the selection +/// should end up. +struct Edit +{ + QString text; + int selectionStart = 0; + int selectionEnd = 0; +}; + +/// Wraps the selection in \p token, or inserts an empty pair with the cursor +/// BETWEEN the tokens when there is no selection. +/// +/// The cursor landing between the tokens is the property a user notices +/// immediately when it is wrong, and it is invisible to a test that only +/// compares the resulting text. +Edit wrap(const QString &text, int start, int end, const QString &token); + +/// `[text](url)`. With a selection the selected text becomes the label and +/// the cursor lands inside the empty parentheses, which is where the user has +/// to type next. With none the cursor lands inside the brackets, since the +/// label is then what gets typed first. +Edit link(const QString &text, int start, int end); + +/// `> ` on every line the selection touches, including a line the selection +/// only starts or ends on. Line-based rather than a wrap, so it cannot be +/// expressed with wrap(). +Edit quote(const QString &text, int start, int end); + +} // namespace MarkdownFormat diff --git a/src/keymap.cpp b/src/keymap.cpp index 76c6b60..0df8450 100644 --- a/src/keymap.cpp +++ b/src/keymap.cpp @@ -54,6 +54,16 @@ QStringList KeyMap::knownActions() QStringLiteral("spam_thread"), QStringLiteral("toggle_unread_thread"), QStringLiteral("flag_thread"), + // Compose and send (item 123). save_message deliberately carries no + // default chord: since item 132 a shortcut is a chosen subset rather + // than a requirement, and writing the raw message to a file is the + // rarely-used escape hatch. Menu reachability is the rule that holds. + QStringLiteral("compose"), + QStringLiteral("reply"), + QStringLiteral("reply_all"), + QStringLiteral("reply_no_quote"), + QStringLiteral("forward"), + QStringLiteral("save_message"), QStringLiteral("focus_query"), QStringLiteral("complete_query"), QStringLiteral("save_query"), @@ -98,6 +108,29 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings() { QStringLiteral("Alt+Down"), QStringLiteral("next_thread") }, { QStringLiteral("Alt+Up"), QStringLiteral("prev_thread") }, { QStringLiteral("Return"), QStringLiteral("open_thread") }, + // Compose and send (item 123), listed where the Message menu presents + // them: composing sits above organising. + // + // PROVISIONAL. The user intends to rework the bindings, and + // Ctrl+Alt+R for reply_no_quote is an imperfect fit: the Ctrl+Alt tier + // elsewhere means a WIDER SCOPE (the five whole-thread actions), not a + // variant of the same scope. + // + // Each was checked against every sequence in this table, not merely + // against the lines above it: these sit near the top, so most of the + // table is BELOW them, Ctrl+Shift+U and Ctrl+Shift+S among it. + // Checking only upwards would miss exactly those. The near misses: + // Ctrl+R is restore, Ctrl+A is select_all and Ctrl+Alt+S is + // spam_thread, so none of these five is a reuse. + // + // save_message gets none. Item 132 made a chord a chosen subset rather + // than a requirement, and this is the escape hatch nobody presses a + // key for. + { QStringLiteral("Ctrl+N"), QStringLiteral("compose") }, + { QStringLiteral("Ctrl+Shift+R"), QStringLiteral("reply") }, + { QStringLiteral("Ctrl+Shift+A"), QStringLiteral("reply_all") }, + { QStringLiteral("Ctrl+Alt+R"), QStringLiteral("reply_no_quote") }, + { QStringLiteral("Ctrl+Shift+F"), QStringLiteral("forward") }, { QStringLiteral("Ctrl+E"), QStringLiteral("archive") }, // Del FIRST, and the order matters twice over. defaultSequenceFor() // returns the first match, and sequenceFor() prefers any binding that diff --git a/src/maildirname.cpp b/src/maildirname.cpp new file mode 100644 index 0000000..6263aec --- /dev/null +++ b/src/maildirname.cpp @@ -0,0 +1,80 @@ +/* + * 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 "maildirname.h" + +#include <QCoreApplication> +#include <QDateTime> +#include <QHostInfo> + +namespace MaildirName { + +/// A fresh Maildir filename for a message being moved between folders, +/// preserving only its `:2,<flags>` suffix. +/// +/// mbsync's manual is explicit about why this exists, under "the more +/// efficient default UID mapping scheme": "it is important that the MUA +/// renames files when moving them between Maildir folders", and "the general +/// expectation is that a completely new filename is generated as if the +/// message was new". +/// +/// The `,U=<n>` infix mbsync writes is its per-folder IMAP UID. Carrying it +/// into another folder makes it a claim about a folder the file is no longer +/// in; moving a message out and back then reinserts a UID the server has +/// since reassigned, and mbsync refuses the folder with `Maildir error: +/// duplicate UID`. Measured on real mail, four collisions in one folder from +/// a single move-and-restore. +/// +/// The FLAGS are kept, deliberately, and that is not a contradiction of +/// "as if the message was new". They record seen, flagged and replied, and +/// `maildir.synchronize_flags` is true, so notmuch reads them back as tags: +/// dropping them would mark every deleted message unread and lose Important +/// on the way to the trash. Only the unique part is regenerated. +QString fresh(const QString &oldName) +{ + // The `:2,` suffix, when there is one. `info` is everything from the + // separator on, so an empty-flag `:2,` is preserved as faithfully as + // `:2,FS`. + QString info; + const int sep = oldName.indexOf(QStringLiteral(":2,")); + if (sep >= 0) + info = oldName.mid(sep); + + // The conventional left-to-right unique part: time, a per-process counter, + // the pid, the host. The counter is what makes two messages moved in the + // same second distinct, which a timestamp alone does not guarantee. + static quint64 counter = 0; + const qint64 now = QDateTime::currentSecsSinceEpoch(); + const QString host = QHostInfo::localHostName().isEmpty() + ? QStringLiteral("localhost") + : QHostInfo::localHostName(); + + return QStringLiteral("%1.M%2P%3Q%4.%5%6") + .arg(now) + .arg(QDateTime::currentMSecsSinceEpoch() % 1000) + .arg(QCoreApplication::applicationPid()) + .arg(++counter) + // A `/` or a `:` in a hostname would break the path or the flag + // separator. Neither is legal in a hostname, so this is belt and + // braces rather than a known case. + .arg(QString(host).replace(QLatin1Char('/'), QLatin1Char('_')) + .replace(QLatin1Char(':'), QLatin1Char('_'))) + .arg(info); +} + +} // namespace MaildirName diff --git a/src/maildirname.h b/src/maildirname.h new file mode 100644 index 0000000..f24bc71 --- /dev/null +++ b/src/maildirname.h @@ -0,0 +1,41 @@ +/* + * 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> + +/// Maildir filename generation, shared by every path that writes a message +/// file: NotmuchWorker::moveMessages() and DraftStore. +/// +/// A namespace rather than a class; there is no state beyond a counter. +namespace MaildirName { + +/// A fresh, unique Maildir filename, preserving \p oldName's flag suffix. +/// +/// A FRESH name, never a reuse. mbsync writes a `,U=<n>` infix that is +/// meaningful only within one folder, and carrying it across a folder +/// boundary produced "Maildir error: duplicate UID" on real mail. Only the +/// `:2,` flag suffix is carried, because the flags describe the message +/// rather than its position. +/// +/// Pass an empty string for a message that has no previous name, which is +/// what a newly composed draft is. +QString fresh(const QString &oldName); + +} // namespace MaildirName diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index dc416ca..9a94a2e 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -26,6 +26,7 @@ #include <QDialog> #include <QDialogButtonBox> #include <QDir> +#include <QFileDialog> #include <QFileInfo> #include <QHBoxLayout> #include <QHeaderView> @@ -47,6 +48,8 @@ #include <QToolButton> #include <QVBoxLayout> +#include "composecontext.h" +#include "composewindow.h" #include "mailsync.h" #include "messageview.h" #include "mimeparser.h" @@ -93,8 +96,42 @@ QString MainWindow::uiStatePath() namespace { /// Overridden only by setLocksPathForTesting(); "/proc/locks" in every real run. QString g_locksPath = QStringLiteral("/proc/locks"); + } // namespace +/// Doc comment on the declaration. Separators and control characters are +/// replaced rather than stripped so a subject carrying one yields a readable +/// name, instead of being truncated to its last segment by the basename +/// reduction Attachment::safeFilename() performs afterwards. +QString MainWindow::defaultMessageFilename(const QString &subject) +{ + QString name = subject.simplified(); + for (QChar &c : name) { + if (c == QLatin1Char('/') || c == QLatin1Char('\\') + || c == QLatin1Char(':') || c.category() == QChar::Other_Control) { + c = QLatin1Char('-'); + } + } + // Long subjects exist and many filesystems stop at 255 bytes. Truncated + // before the extension is added, so the cut cannot eat it. + name.truncate(120); + name = name.trimmed(); + + // A leading dot makes the file HIDDEN on every Unix desktop, and a subject + // beginning with one is ordinary ("...and another thing", or a traversal + // whose separators were just replaced above, leaving "..-..-etc-passwd"). + // The write succeeds and the user cannot see the file they just saved. + // Measured: QDir::entryList omits it without QDir::Hidden, which is how + // this was found. + while (name.startsWith(QLatin1Char('.'))) + name.remove(0, 1); + name = name.trimmed(); + + if (name.isEmpty()) + name = QStringLiteral("message"); + return name + QStringLiteral(".eml"); +} + void MainWindow::setLocksPathForTesting(const QString &path) { g_locksPath = path; @@ -202,6 +239,105 @@ void MainWindow::closeEvent(QCloseEvent *event) return; } + // Case 3 FIRST, because it is the one where saving is what is already not + // working: in case 2 nothing is lost by saving, here quitting loses that + // text, so the dialog must say so plainly rather than offering a save that + // will fail again. + QStringList failedSaves; + for (const QPointer<ComposeWindow> &composer : m_composers) { + if (composer && composer->lastSaveFailed()) + failedSaves.append(composer->windowTitle()); + } + if (!failedSaves.isEmpty()) { + // The titles, not merely the count. The spec requires the dialog to + // NAME what could not be saved: "2 messages could not be saved" tells + // a user with four composers open nothing about which two to rescue. + // + // The list is a separate paragraph rather than interpolated into the + // sentence. The count and the list combine differently across + // languages, and a translator given "%n message(s) ...: %1" has to + // keep an English clause order Italian does not share. + QMessageBox box(this); + box.setIcon(QMessageBox::Warning); + box.setWindowTitle(tr("A draft could not be saved")); + box.setText(tr("%n message(s) could not be saved to the drafts " + "folder. Quitting now loses that text.", "", + failedSaves.size())); + box.setInformativeText(failedSaves.join(QLatin1Char('\n'))); + box.setStandardButtons(QMessageBox::Retry | QMessageBox::Discard + | QMessageBox::Cancel); + box.setDefaultButton(QMessageBox::Cancel); + const int answer = box.exec(); + + if (answer == QMessageBox::Cancel) { + event->ignore(); + return; + } + if (answer == QMessageBox::Retry) { + bool allSaved = true; + for (const QPointer<ComposeWindow> &composer : m_composers) { + if (composer && composer->lastSaveFailed() + && !composer->saveDraftNow()) { + allSaved = false; + } + } + if (!allSaved) { + // Still failing: stay open rather than quitting on a retry + // that did not work, which would lose exactly the text the + // user pressed Retry to keep. + event->ignore(); + return; + } + } + } + + // Case 2: ONE dialog whatever the count. Three modals in a row is worse + // than a coarse answer, so it applies to all of them and there is no + // per-draft choice. + const QList<QPointer<ComposeWindow>> blocking = composersBlockingQuit(); + if (!blocking.isEmpty()) { + QStringList titles; + titles.reserve(blocking.size()); + for (const QPointer<ComposeWindow> &composer : blocking) + titles.append(composer->windowTitle()); + + QMessageBox box(this); + box.setIcon(QMessageBox::Question); + box.setWindowTitle(tr("Messages still being composed")); + // "Discard" discards UNSAVED EDITS, not drafts: a draft already + // autosaved stays in the folder. The wording must not read as + // "delete my three messages". + box.setText(tr("%n message(s) are still being composed. Drafts " + "already saved stay in the drafts folder either way.", + "", blocking.size())); + box.setInformativeText(titles.join(QLatin1Char('\n'))); + box.setStandardButtons(QMessageBox::Save | QMessageBox::Discard + | QMessageBox::Cancel); + box.setDefaultButton(QMessageBox::Save); + const int answer = box.exec(); + + if (answer == QMessageBox::Cancel) { + event->ignore(); + return; + } + if (answer == QMessageBox::Save) { + // Null-checked per iteration, because `blocking` was computed + // BEFORE exec() and a nested event loop processes deleteLater(). + // The dialog is window-modal to this window only, so a user can + // close a composer while it is up; measured in a standalone Qt + // program, that composer is destroyed before exec() returns. + // Without this check the save runs on freed memory at the exact + // moment the application promised to preserve the text, and the + // remaining composers' drafts are never written because the crash + // happens mid-loop. Case 3's Retry loop above has always had the + // equivalent guard; this one had dropped it. + for (const QPointer<ComposeWindow> &composer : blocking) { + if (composer) + composer->saveDraftNow(); + } + } + } + if (!m_closeApproved && pendingEditCount() > 0 && m_config.syncOnExit() != Config::SyncOnExit::Never) { @@ -286,6 +422,33 @@ void MainWindow::closeEvent(QCloseEvent *event) } } + // Every composer goes with the window, and this is the LAST thing before + // the close is accepted: every route that turns back (Cancel, a failed + // sync, a refused save) has already returned above, so reaching here means + // the application really is quitting. + // + // A composer is deliberately parentless, so that it appears in the task + // switcher and stays usable while the main window is. Qt therefore does not + // take it down with this window, and it kept the process alive: the main + // window vanished, the composer stayed on screen with nothing behind it, + // and closing it then raised the unsaved-edits dialog for a session that + // had already ended. + // + // Closing rather than deleting. WA_DeleteOnClose is set on every composer, + // so close() is what frees them, and it lets ComposeWindow::closeEvent() + // run its own draft handling on the way out. The drafts have already been + // saved by the dialogs above, so that pass has nothing left to do; going + // through it anyway keeps ONE exit path rather than a second one that has + // to be kept in step. + // + // Iterating a COPY: closing a composer runs the `closed` handler, which + // mutates m_composers, and mutating a container mid-iteration is undefined. + const QList<QPointer<ComposeWindow>> composers = m_composers; + for (const QPointer<ComposeWindow> &composer : composers) { + if (composer) + composer->close(); + } + saveUiState(); QMainWindow::closeEvent(event); } @@ -758,6 +921,409 @@ void MainWindow::buildUi() setWindowTitle(QStringLiteral("qtmaildir %1").arg(QTMAILDIR_VERSION)); } +void MainWindow::composeNew() +{ + // m_accountBox->currentData() is how the selected account is read + // everywhere else in this file; there is no currentAccountKey() accessor. + // Empty means the All accounts view, which falls through to rule 2. + const QString accountKey = ComposeContextBuilder::accountForNew( + m_config, m_accountBox->currentData().toString()); + if (accountKey.isEmpty()) { + // Unreachable while the action is disabled, which is the only state + // this can be true in. Reported rather than returning silently: an + // action that runs and does nothing is the failure mode item 105 + // records as "the key does nothing". + showTransientStatus(tr("No account is configured to send mail")); + return; + } + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = accountKey; + context.seedHtml = m_config.compose().sendHtml; + + openComposer(context); +} + +void MainWindow::composeReply(ComposeContext::Kind kind, bool quote) +{ + // messageScopeFor() semantics, NOT threadFor(): a thread row means the one + // message its card shows, a reply row means itself. Replying to a thread + // is meaningless; a reply answers a message. + // + // It takes a QModelIndexList, not a single index, so the current index is + // wrapped rather than passed bare. + const ActionScope scope = + m_model->messageScopeFor({ m_threadView->currentIndex() }); + if (scope.messageIds.isEmpty()) { + showTransientStatus(tr("No message is selected")); + return; + } + + // Built from the DATABASE, never from the model. The model's data comes + // from the query, so a row whose state has not been re-queried carries + // stale values, and a reply built from a stale row would carry the wrong + // recipients. This is the rule Restore already follows. + requestMessageForCompose(scope.messageIds.first(), kind, quote); +} + +void MainWindow::requestMessageForCompose(const QString &messageId, + ComposeContext::Kind kind, + bool quote) +{ + if (messageId.isEmpty()) + return; + + m_pendingCompose = { messageId, kind, quote, true }; + + // The same generation every other worker request carries, so a reply that + // arrives after the query moved on is discarded rather than opening a + // composer on a message the user is no longer looking at. + QMetaObject::invokeMethod(m_worker, "loadMessage", Qt::QueuedConnection, + Q_ARG(QString, messageId), + Q_ARG(quint64, m_generation)); +} + +void MainWindow::openComposerFor(const MessageRef &ref, + ComposeContext::Kind kind, bool quote) +{ + MimeParser parser; + const ParsedMessage original = parser.parse(ref.filePath); + if (!original.ok) { + showTransientStatus(tr("That message could not be read")); + return; + } + + ComposeContext context; + context.kind = kind; + context.originalPath = ref.filePath; + + const bool replyAll = kind == ComposeContext::Kind::ReplyAll; + const bool forwarding = kind == ComposeContext::Kind::Forward; + + if (!forwarding) { + ComposeContextBuilder::recipientsForReply( + original, replyAll, ComposeContextBuilder::ownAddresses(m_config), + &context.to, &context.cc); + + // Threading headers on a reply only. A forward starts a new + // conversation: carrying In-Reply-To would file it under the thread it + // was forwarded out of, in the RECIPIENT's client. + context.inReplyTo = original.messageId; + context.references = ComposeContextBuilder::referencesForReply(original); + } + + context.subject = forwarding + ? ComposeContextBuilder::forwardSubject(original.subject) + : ComposeContextBuilder::replySubject(original.subject); + + if (quote) + context.quotedBody = ComposeContextBuilder::quoteBody(original); + + // Forward seeds from the CONFIG, Reply from the original. The split is + // the spec's and Config::ComposeSettings::sendHtml states it too: an HTML + // part in the original is a fact about the SENDER's software, so it is the + // right seed when answering them and says nothing about a forward, which + // is a new message to somebody else. composeNew() already reads the config + // for the same reason. + context.seedHtml = forwarding ? m_config.compose().sendHtml + : original.hasHtml(); + + // accountForReply() takes messagePaths PLURAL because notmuch can return + // several filenames for one id, and it disambiguates between them by + // recipient. That disambiguation is INERT here, and the reason is upstream + // rather than a decision made at this call site: NotmuchWorker::loadMessage + // builds its MessageRef from notmuch_message_get_filename(), the SINGULAR + // accessor, so nothing in the pipeline ever carries more than one path and + // the list below can never hold more than one element. Backlog item 137 + // carries the fix (MessageRef gains a filePaths list populated from + // notmuch_message_get_filenames()); until then a message that arrived at + // two accounts can open its reply from the wrong one. + const QStringList recipients = context.to + context.cc; + context.accountKey = ComposeContextBuilder::accountForReply( + m_config, { ref.filePath }, recipients, m_mailRoot); + + if (context.accountKey.isEmpty() + || !m_config.account(context.accountKey).canSend()) { + // The enablement pass should already have stopped this, but it answers + // from the model's path while this answers from the database's, and + // the two can disagree on a row that has not been re-queried. + showTransientStatus( + tr("That message arrived at an account that cannot send")); + return; + } + + openComposer(context); +} + +void MainWindow::openComposer(const ComposeContext &context) +{ + if (m_mailRoot.isEmpty()) { + // Without the root a draft cannot be written anywhere, and a composer + // that silently cannot autosave is the state the quit path's honesty + // depends on not being in. + showTransientStatus(tr("The Maildir root is not known yet")); + return; + } + + auto *composer = new ComposeWindow(context, m_config, m_mailRoot); + composer->setAttribute(Qt::WA_DeleteOnClose); + m_composers.append(QPointer<ComposeWindow>(composer)); + + // Compaction, and ONLY compaction. The QPointer above is what keeps + // composersBlockingQuit() safe against a destroyed window, since it nulls + // on destruction; this drops the entry so the list does not accumulate + // nulls for the session's lifetime. Neither replaces the other: without + // the signal the list leaks entries, without the QPointer it dangles. + connect(composer, &ComposeWindow::closed, this, + [this](ComposeWindow *which) { + m_composers.removeIf([which](const QPointer<ComposeWindow> &p) { + return p.isNull() || p.data() == which; + }); + }); + + composer->show(); +} + +QList<QPointer<ComposeWindow>> MainWindow::composersBlockingQuit() const +{ + QList<QPointer<ComposeWindow>> blocking; + for (const QPointer<ComposeWindow> &composer : m_composers) { + if (composer && composer->hasUnsavedEdits()) + blocking.append(composer); + } + return blocking; +} + +ComposeWindow *MainWindow::openComposerForTest() +{ + const QString accountKey = + ComposeContextBuilder::accountForNew(m_config, QString()); + if (accountKey.isEmpty()) + return nullptr; + + ComposeContext context; + context.kind = ComposeContext::Kind::New; + context.accountKey = accountKey; + + const int before = m_composers.size(); + openComposer(context); + if (m_composers.size() == before) + return nullptr; + return m_composers.constLast().data(); +} + +QList<ComposeWindow *> MainWindow::openComposersForTest() const +{ + QList<ComposeWindow *> live; + for (const QPointer<ComposeWindow> &composer : m_composers) { + if (composer) + live.append(composer.data()); + } + return live; +} + +int MainWindow::openComposerCount() const +{ + int live = 0; + for (const QPointer<ComposeWindow> &composer : m_composers) { + if (composer) + ++live; + } + return live; +} + +void MainWindow::markComposersDirtyForTest() +{ + // Through the real edit path: the body editor's own textChanged is what + // ComposeWindow::markDirty() is connected to, so inserting text here + // exercises the same route typing does. Setting a dirty flag directly + // would pass against a composer that never notices an edit at all. + // + // QTextCursor rather than QTest::keyClicks, so production code does not + // have to link QtTest. + for (const QPointer<ComposeWindow> &composer : m_composers) { + if (!composer) + continue; + if (auto *body = composer->findChild<QPlainTextEdit *>( + QStringLiteral("body"))) { + body->textCursor().insertText(QStringLiteral("x")); + } + } +} + +QString MainWindow::accountForCurrentMessage() const +{ + if (m_mailRoot.isEmpty()) + return {}; + + const QModelIndex current = m_threadView->currentIndex(); + if (!current.isValid()) + return {}; + + // The model's path, deliberately. This decides whether a CONTROL is live, + // which a stale path answers well enough; the context that actually opens + // a composer resolves the account again from the database. Asking the + // worker here would make every selection change a round trip. + // + // The two sources are in DIFFERENT FORMS and normalising them is not + // tidying. ThreadSummary::firstMessagePath is RELATIVE to the mail root, + // because runQuery() reduces it with relativeFilePath() so the UI can + // compare it against an account's maildir; MessageNode::filePath is + // ABSOLUTE, because MimeParser opens it. accountOwning() builds an + // absolute prefix, so handing it the relative one matches no account at + // all and every thread row reports no account, which disables the reply + // family on mail from an account that can perfectly well send. Measured: + // it did exactly that until the guard test caught it. + QString path; + if (m_model->isMessageRow(current)) { + path = m_model->messageAt(current).filePath; + } else { + path = m_model->threadFor(current).firstMessagePath; + } + if (path.isEmpty()) + return {}; + + const QString absolute = QDir::isAbsolutePath(path) + ? path + : QDir(m_mailRoot).absoluteFilePath(path); + + return ComposeContextBuilder::accountForReply(m_config, { absolute }, + QStringList(), m_mailRoot); +} + +void MainWindow::updateComposeActions() +{ + // The reply family is disabled on mail that arrived at an account which + // cannot send. save_message is deliberately NOT in this list: it is the + // escape hatch for exactly that case, writing the raw message to a file + // that can be attached to a new message from an account that can send. + const QString replyAccount = accountForCurrentMessage(); + const bool canReply = !replyAccount.isEmpty() + && m_config.account(replyAccount).canSend(); + + static const QStringList kReplyFamily = { + QStringLiteral("reply"), QStringLiteral("reply_all"), + QStringLiteral("reply_no_quote"), QStringLiteral("forward") + }; + for (const QString &name : kReplyFamily) { + if (QAction *action = m_actions.value(name)) + action->setEnabled(canReply); + } + + // The ribbon appears only when an account was identified AND it cannot + // send. An unidentified account is not a receive-only one: it is a message + // whose file no account owns, and naming no account in a ribbon that + // exists to name one would be worse than staying quiet. + const bool receiveOnly = + !replyAccount.isEmpty() && !m_config.account(replyAccount).canSend(); + m_messageView->setReceiveOnlyAccount(receiveOnly ? replyAccount + : QString()); + + // compose is disabled only when NO account can send. A read-only + // installation is valid and is not warned about. + if (QAction *compose = m_actions.value(QStringLiteral("compose"))) + compose->setEnabled(!m_config.sendingAccounts().isEmpty()); +} + +void MainWindow::saveDisplayedMessage(const QString &chosenDirectory) +{ + const QModelIndex current = m_threadView->currentIndex(); + const ActionScope scope = m_model->messageScopeFor({ current }); + if (scope.messageIds.isEmpty()) { + showTransientStatus(tr("No message is selected")); + return; + } + + // The path from the model, which is what the pane is rendering. Unlike a + // reply, a copy of the wrong file is visible to the user the moment they + // open it, so this does not need the database round trip a reply does. + QString sourcePath; + QString subject; + if (m_model->isMessageRow(current)) { + const MessageNode node = m_model->messageAt(current); + sourcePath = node.filePath; + subject = node.subject; + } else { + const ThreadSummary thread = m_model->threadFor(current); + sourcePath = thread.firstMessagePath; + subject = thread.subject; + } + if (sourcePath.isEmpty()) { + showTransientStatus(tr("That message's file could not be found")); + return; + } + + // Relative for a thread row, absolute for a message row. The same + // asymmetry accountForCurrentMessage() documents at length. + if (!QDir::isAbsolutePath(sourcePath) && !m_mailRoot.isEmpty()) + sourcePath = QDir(m_mailRoot).absoluteFilePath(sourcePath); + + if (!QFileInfo::exists(sourcePath)) { + showTransientStatus(tr("That message's file could not be found")); + return; + } + + // The dialog only when no directory was supplied. A test supplies one, + // because the modal cannot be driven under the offscreen platform and the + // containment check below is the only line guarding the write. + const QString directory = + chosenDirectory.isEmpty() + ? QFileDialog::getExistingDirectory( + this, tr("Save message to"), + QStandardPaths::writableLocation( + QStandardPaths::DownloadLocation)) + : chosenDirectory; + if (directory.isEmpty()) + return; // cancelled + + // The default name is derived from the SUBJECT, which is input from a + // stranger: it may carry path separators, "..", or nothing usable. The + // same rules the attachment path follows, and the same helpers, rather + // than a second implementation that has to be kept correct separately. + Attachment naming; + naming.filename = defaultMessageFilename(subject); + const QString safeName = naming.safeFilename(); + + // Disambiguated rather than overwritten, matching what the attachment bar + // does. Attachment::saveWithoutOverwriting() is the same rule and cannot + // be reused here because it writes an Attachment's own bytes, while this + // COPIES a file; the naming is duplicated, the behaviour is not. + // + // The earlier version deleted an existing same-named file, on the + // reasoning that a save the user just confirmed a location for should not + // silently do nothing. That is right about the failure and wrong about the + // remedy: two messages very often share a subject, so the second save + // would destroy the first, and QFile::copy's refusal is a reason to pick + // another name rather than to delete somebody's file. + const QFileInfo naming_info(safeName); + const QString base = naming_info.completeBaseName(); + const QString suffix = naming_info.suffix().isEmpty() + ? QString() + : QLatin1Char('.') + naming_info.suffix(); + const QDir dir(directory); + QString candidate = safeName; + for (int n = 2; dir.exists(candidate); ++n) + candidate = QStringLiteral("%1 (%2)%3").arg(base).arg(n).arg(suffix); + + const QString target = dir.absoluteFilePath(candidate); + + // Compared as PATHS, never with startsWith(): "/tmp/safe-evil" passes a + // startsWith("/tmp/safe") check while being a sibling directory. + if (!Attachment::isPathInsideDirectory(directory, target)) { + showTransientStatus(tr("Refusing to write outside %1") + .arg(QDir::cleanPath( + QDir(directory).absolutePath()))); + return; + } + + if (!QFile::copy(sourcePath, target)) { + showTransientStatus(tr("Could not write %1").arg(target)); + return; + } + showTransientStatus(tr("Saved %1").arg(target)); +} + QAction *MainWindow::addAction(const QString &name, const QString &text, const QString &description, const std::function<void()> &handler) @@ -1124,6 +1690,34 @@ void MainWindow::registerActions() addAction(QStringLiteral("quit"), tr("&Quit"), tr("Quit qtmaildir"), [this]() { close(); }); + // Compose and send (item 123). The handlers are empty: this is the + // registration, so the three coverage tests + // (everyKnownActionIsRegistered, everyActionCarriesAnIcon and + // everyActionIsReachableFromAMenu) cover the composer from the first + // commit rather than being satisfied once it is finished. + // + // Reply and reply-without-quoting are the same Kind with and without a + // seeded body, which is why the quoting is a parameter rather than a + // fourth Kind: the recipients, the subject prefix and the threading + // headers are identical, and only the body differs. + addAction(QStringLiteral("compose"), tr("&New message"), + tr("Compose a new message"), [this]() { composeNew(); }); + addAction(QStringLiteral("reply"), tr("Re&ply"), + tr("Reply to the displayed message"), + [this]() { composeReply(ComposeContext::Kind::Reply, true); }); + addAction(QStringLiteral("reply_all"), tr("Reply to a&ll"), + tr("Reply to the sender and every other recipient"), + [this]() { composeReply(ComposeContext::Kind::ReplyAll, true); }); + addAction(QStringLiteral("reply_no_quote"), tr("Reply without "ing"), + tr("Reply with an empty body"), + [this]() { composeReply(ComposeContext::Kind::Reply, false); }); + addAction(QStringLiteral("forward"), tr("&Forward"), + tr("Forward the displayed message"), + [this]() { composeReply(ComposeContext::Kind::Forward, true); }); + addAction(QStringLiteral("save_message"), tr("Sa&ve message as..."), + tr("Write the raw message to a file"), + [this]() { saveDisplayedMessage(); }); + // A binding the user wrote for an action that does not exist would be // silently dead. KeyMap warns about unknown names, but only a check here // catches the reverse: a known action nothing implements. @@ -1135,6 +1729,10 @@ void MainWindow::registerActions() // and offering "Mark all read" against nothing is a live control that does // nothing. updateViewWideActions(); + + // Compose and the reply family, for the same reason: QAction starts + // enabled, so a window with nothing selected would offer a live Reply. + updateComposeActions(); } void MainWindow::buildMenus() @@ -1154,6 +1752,18 @@ void MainWindow::buildMenus() editMenu->addAction(m_actions.value(QStringLiteral("select_all"))); auto *messageMenu = menuBar()->addMenu(tr("&Message")); + // Composing sits above organising (item 123). The spec called for a new + // top-level Message menu and this one already existed, so the six join it: + // two menus named Message would be a defect. + messageMenu->addAction(m_actions.value(QStringLiteral("compose"))); + messageMenu->addSeparator(); + messageMenu->addAction(m_actions.value(QStringLiteral("reply"))); + messageMenu->addAction(m_actions.value(QStringLiteral("reply_all"))); + messageMenu->addAction(m_actions.value(QStringLiteral("reply_no_quote"))); + messageMenu->addAction(m_actions.value(QStringLiteral("forward"))); + messageMenu->addSeparator(); + messageMenu->addAction(m_actions.value(QStringLiteral("save_message"))); + messageMenu->addSeparator(); messageMenu->addAction(m_actions.value(QStringLiteral("archive"))); messageMenu->addAction(m_actions.value(QStringLiteral("delete"))); // Beside Delete, whose inverse it is. Greyed outside the trash view @@ -1282,6 +1892,22 @@ void MainWindow::buildMenus() { QStringLiteral("spam_thread"), QStringLiteral("mail-mark-junk") }, { QStringLiteral("toggle_unread_thread"), QStringLiteral("mail-mark-unread") }, { QStringLiteral("flag_thread"), QStringLiteral("mail-mark-important") }, + + // Compose and send (item 123). reply_no_quote SHARES reply's icon for + // the same reason the five above share theirs: it never reaches the + // toolbar, it is a menu entry that always carries its text, and + // "Reply without quoting" beside the reply icon is the honest pairing. + // It is named in the exception list in noTwoActionsShareAnIcon(), so + // putting it on the toolbar fails that test rather than passing + // silently. + { QStringLiteral("compose"), QStringLiteral("mail-message-new") }, + { QStringLiteral("reply"), QStringLiteral("mail-reply-sender") }, + { QStringLiteral("reply_all"), QStringLiteral("mail-reply-all") }, + { QStringLiteral("reply_no_quote"), QStringLiteral("mail-reply-sender") }, + { QStringLiteral("forward"), QStringLiteral("mail-forward") }, + // NOT bookmark-new, which save_query uses: this really does write a + // file the user names, which is exactly what the disk shape means. + { QStringLiteral("save_message"), QStringLiteral("document-save-as") }, }; for (auto it = themeIcons.cbegin(); it != themeIcons.cend(); ++it) { QAction *action = m_actions.value(it.key()); @@ -1340,6 +1966,16 @@ void MainWindow::buildMenus() // anything this code can see. const int iconSize = m_config.toolbarIconSize(); toolBar->setIconSize(QSize(iconSize, iconSize)); + + // First, because composing and replying are what a user reaches for most + // (item 123). These TWO only: the other four are menu-and-key, which is + // what keeps the no-duplicate-icons rule satisfiable, since reply_no_quote + // shares reply's icon and an icon-only toolbar would make the two buttons + // indistinguishable. + toolBar->addAction(m_actions.value(QStringLiteral("compose"))); + toolBar->addAction(m_actions.value(QStringLiteral("reply"))); + toolBar->addSeparator(); + QAction *syncAction = m_actions.value(QStringLiteral("sync")); // Carried over from the QPushButton this replaced: with no command // configured the control is disabled, and the tooltip is the only thing @@ -1638,6 +2274,8 @@ void MainWindow::wireWorker() this, &MainWindow::onWorkerError); connect(m_worker, &NotmuchWorker::allTagsReady, this, &MainWindow::onAllTagsReady); + connect(m_worker, &NotmuchWorker::mailRootReady, + this, &MainWindow::onMailRootReady); connect(m_worker, &NotmuchWorker::countsReady, this, &MainWindow::onCountsReady); connect(m_worker, &NotmuchWorker::databaseStatsReady, @@ -1674,6 +2312,11 @@ void MainWindow::wireWorker() // as the database can be read. Nothing waits on the answer: requestAllTags // stays silent when the database cannot be opened. requestAllTags(); + + // The Maildir root, which this window cannot derive (item 124). Asked once: + // it does not change while the application runs. Nothing waits on it + // either; the reply family is gated on send_command, not on this. + QMetaObject::invokeMethod(m_worker, "requestMailRoot", Qt::QueuedConnection); } void MainWindow::requestAllTags() @@ -1694,6 +2337,17 @@ void MainWindow::onAllTagsReady(const QStringList &tags) m_queryCompleter->setTags(tags); } +void MainWindow::onMailRootReady(const QString &mailRoot) +{ + m_mailRoot = mailRoot; + + // The enablement pass reads m_mailRoot to resolve which account owns the + // displayed message, so it answers "no account" until this arrives. A + // window that had already selected a row would otherwise keep the reply + // family greyed out until the next selection change. + updateComposeActions(); +} + QList<MainWindow::PlaceholderLine> MainWindow::placeholderLines() const { // One list of (query, label-maker) pairs rather than two arrays indexed in @@ -2647,6 +3301,11 @@ void MainWindow::onSelectionChanged() if (changed) onThreadSelected(current, QModelIndex()); } + + // Which account owns the displayed message decides whether the reply + // family is live and whether the ribbon shows, so it is re-answered + // whenever the displayed message can have changed. + updateComposeActions(); return; } @@ -2658,6 +3317,7 @@ void MainWindow::onSelectionChanged() if (m_statusLabel->text() == m_selectionMessage) m_statusLabel->clear(); m_selectionMessage.clear(); + updateComposeActions(); return; } @@ -2699,6 +3359,10 @@ void MainWindow::onSelectionChanged() m_currentMessageThreadId.clear(); m_messageView->clear(); showPlaceholderPane(); + + // A multi-row selection displays no message, so there is no account to + // reply from and no ribbon to show. + updateComposeActions(); } void MainWindow::onThreadSelected(const QModelIndex ¤t, @@ -2836,6 +3500,61 @@ void MainWindow::onThreadSelected(const QModelIndex ¤t, void MainWindow::onMessageLoaded(const QVector<MessageRef> &messages, quint64 generation) { + // A compose request comes through this same signal rather than through a + // worker signal of its own, so it is answered before the render guards + // below: those exist to protect the PANE, and none of them applies to + // opening a composer. + // + // Matched by MESSAGE ID, not merely by a pending flag. The compose request + // and the pane share one loadMessage slot and one messageLoaded signal, so + // a pane load already in flight when the user presses Reply arrives FIRST + // and carries a different message: consuming it on the flag alone would + // open a composer on whichever message the pane happened to be loading. + // A non-matching reply falls through to the pane, which is what it is. + if (m_pendingCompose.active) { + const auto it = std::find_if( + messages.cbegin(), messages.cend(), + [this](const MessageRef &ref) { + return ref.messageId == m_pendingCompose.messageId; + }); + if (it != messages.cend()) { + const PendingCompose request = m_pendingCompose; + m_pendingCompose = {}; + + // The generation guard still applies: a query that moved on means + // the row the user asked from is gone. + if (generation == m_generation) + openComposerFor(*it, request.kind, request.quote); + + // A compose load carries no pane update: m_currentMessageId is + // untouched by requestMessageForCompose(), so falling through + // would repaint the pane with a message it did not select. + return; + } + + // No match, and the request is DISARMED rather than left waiting. + // + // Leaving it armed was a two-stage defect. The immediate half is that + // Reply silently does nothing when the message is not in the index, + // which is item 105's "the key does nothing". The delayed half is + // worse: the request stays armed with a specific message id, and the + // pane's own loads are the traffic being matched against, so merely + // SELECTING that message later would match, open a composer nobody + // asked for, and return before renderMessages() leaving the pane blank + // on the row just clicked. + // + // Only an EMPTY reply disarms it, and that asymmetry is the point. + // loadMessage() emits an empty list precisely when the id resolved to + // nothing, so that reply belongs to this request and says it failed. + // A NON-empty reply naming other messages is the pane's own load + // crossing ours, which is the race the id match exists to survive; + // disarming on it would reintroduce that race from the other side. + if (messages.isEmpty()) { + m_pendingCompose = {}; + showTransientStatus(tr("That message is no longer indexed")); + } + } + // A stale generation means the query moved on. A reply landing after the // selection grew past one row would paint a message back over a pane that // was deliberately blanked: loadMessage crosses to the worker on a queued diff --git a/src/mainwindow.h b/src/mainwindow.h index 8e483d2..ea3ba61 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -63,6 +63,7 @@ class MailSync; class NotmuchWorker; class QueryCompleter; class TagRulesDialog; +class ComposeWindow; class MainWindow : public QMainWindow { @@ -310,6 +311,102 @@ public: onRulePreviewRequested(query); } + /// The open composers with unsaved edits, which the quit path asks about. + /// + /// PRODUCTION code, not a test accessor: closeEvent() reads it. Skips a + /// null QPointer, which is a composer the user already closed and whose + /// closed() signal has not compacted the list yet. + /// + /// Returns QPointers rather than raw pointers, and that is a SAFETY + /// property rather than a style. The quit path holds this list across + /// QMessageBox::exec(), and a nested event loop PROCESSES deleteLater(): + /// measured in a standalone Qt program, a parentless WA_DeleteOnClose + /// window closed while a modal is up is destroyed BEFORE exec() returns. + /// The dialog is window-modal to this window only, so the composers stay + /// interactive and the user really can close one from under it. A raw list + /// dangles there, and it dangles at the exact moment the application + /// promised to preserve their text. + QList<QPointer<ComposeWindow>> composersBlockingQuit() const; + + /// Opens a composer on a blank message from the first account that can + /// send, for a test that needs one open without a modal file dialog or a + /// selected row. Returns nullptr when no account can send. + ComposeWindow *openComposerForTest(); + + /// How many composers the registry currently holds, counting only entries + /// that are still alive. + /// + /// A nulled QPointer is NOT counted, so this cannot by itself distinguish + /// "the entry was removed" from "the entry is still there but nulled". + /// That distinction is what closingAComposerCompactsTheRegistry() exists + /// to make, and it makes it by asserting this reaches zero after a close: + /// only compaction can empty the list, since a nulled entry would leave + /// m_composers non-empty while this still reported zero. + int openComposerCount() const; + + /// Types a character into every open composer, which is what makes it + /// dirty. A test seam over the real edit path rather than a flag setter: + /// setting m_dirty directly would pass against a composer that never + /// notices an edit at all. + void markComposersDirtyForTest(); + + /// The Maildir root as the worker reported it, for the split-index test. + QString mailRootForTesting() const { return m_mailRoot; } + + /// Runs save_message into \p directory instead of asking for one. + /// + /// The file dialog is a modal the offscreen platform cannot click, and the + /// containment check is the only line guarding the write, so without this + /// seam no test can reach the guard it is named after. + void saveDisplayedMessageForTest(const QString &directory) + { + saveDisplayedMessage(directory); + } + + /// Builds a compose context from \p ref and opens the composer, which is + /// the production line openComposerFor() runs. A test that builds a + /// ComposeContext by hand instead proves only that ComposeWindow honours + /// what it is given, and cannot see which SOURCE a field came from. + void openComposerForTest(const MessageRef &ref, ComposeContext::Kind kind, + bool quote) + { + openComposerFor(ref, kind, quote); + } + + /// Arms a compose request without a selected row, so a test can request + /// one for an id the database does not hold. + void requestMessageForComposeForTest(const QString &messageId, + ComposeContext::Kind kind, bool quote) + { + requestMessageForCompose(messageId, kind, quote); + } + + /// Whether a compose request is still waiting for its message. + /// + /// A request that never disarms is the defect this exposes: it stays armed + /// with a message id and hijacks the next pane load for that message. + bool composeRequestPendingForTest() const { return m_pendingCompose.active; } + + /// The live composers, for a test that needs to close them. + /// + /// Defined in the .cpp: dereferencing a QPointer needs the complete type, + /// and ComposeWindow is only forward-declared here. + QList<ComposeWindow *> openComposersForTest() const; + + /// A default filename for a saved message, derived from its subject. + /// + /// Public and static so a test can assert on it with a hostile subject. + /// It was a file-local helper unreachable from any test, and the test + /// named after its defences asserted on Attachment's helpers directly + /// instead: three separate mutations left that test green. CLAUDE.md's + /// "a probe can be correct and still measure nothing, by being pointed at + /// the wrong object". + /// + /// The subject is UNTRUSTED, so this produces a CANDIDATE rather than a + /// safe name: the caller passes it through Attachment::safeFilename(), + /// which reduces it to a plain basename. + static QString defaultMessageFilename(const QString &subject); + protected: void closeEvent(QCloseEvent *event) override; @@ -481,6 +578,11 @@ private slots: void onTagsApplied(const TagChange &change); void onAllTagsReady(const QStringList &tags); + /// The Maildir root, answered once at startup. Enables nothing on its own: + /// the composer needs it, and the reply family is gated on the account's + /// send_command rather than on this having arrived. + void onMailRootReady(const QString &mailRoot); + /// Thread counts for the placeholder's helper lines, in the order /// requestPlaceholderCounts() asked for them. void onCountsReady(const QVector<int> &counts, quint64 generation); @@ -594,6 +696,63 @@ private: /// that populates. void showMaildirOverview(); + /// Opens a composer on a blank message (item 123). + void composeNew(); + + /// Opens a composer seeded from the displayed message (item 123). + /// + /// `kind` chooses reply, reply-all or forward; `quote` is what separates + /// reply from reply-without-quoting, which are the same kind with and + /// without a seeded body. + /// + /// Resolves through ThreadListModel::messageScopeFor(), NOT threadFor(): a + /// thread row means the one message its card shows. Replying to a thread + /// is meaningless, a reply answers a message. + void composeReply(ComposeContext::Kind kind, bool quote); + + /// Asks the worker for \p messageId's current file, then opens a composer. + /// + /// The round trip is the point. The context is built from the DATABASE and + /// never from the model, which is the rule Restore already follows: the + /// model's paths and tags come from the query, so a row that has not been + /// re-queried carries stale values and a reply built from one would go to + /// the wrong recipients. + void requestMessageForCompose(const QString &messageId, + ComposeContext::Kind kind, bool quote); + + /// Builds the context from a parsed message and shows the composer. + /// Called from onMessageLoaded() when a compose request is outstanding. + void openComposerFor(const MessageRef &ref, ComposeContext::Kind kind, + bool quote); + + /// Constructs a ComposeWindow, registers it and shows it. + void openComposer(const ComposeContext &context); + + /// Writes the displayed message's raw file somewhere the user chooses. + /// + /// Never disabled, including on a receive-only account: it is the escape + /// hatch for exactly that case, writing the raw message to a file that can + /// be attached to a new message from an account that can send. + /// + /// \p directory defaults to empty, which raises the file dialog. A test + /// passes one instead, via saveDisplayedMessageForTest(): the modal cannot + /// be driven under the offscreen platform, and the containment check below + /// it is the only line actually guarding the write, so with the dialog + /// inline no test could reach that line at all. + void saveDisplayedMessage(const QString &directory = QString()); + + /// The account a reply to the displayed message would send from, or empty + /// when there is no displayed message or no account owns its file. + /// + /// Read by the enablement pass, which is why it must not need a worker + /// round trip: it answers from the model's path, which is good enough to + /// decide whether a control is live. The context that actually opens a + /// composer resolves the account again from the database. + QString accountForCurrentMessage() const; + + /// Puts the reply family and compose into their real enabled state. + void updateComposeActions(); + /// Creates a QAction, binds it to the sequence KeyMap holds for `name`, /// and registers it. `name` is the action name used in [keys]. QAction *addAction(const QString &name, const QString &text, @@ -1226,6 +1385,40 @@ private: /// back without clobbering a message some other action put there. QString m_selectionMessage; + /// The Maildir root, from the worker (item 124, and this window has no + /// other way to know it). + /// + /// There is no Config::maildirPath() by design: notmuch owns the path and + /// duplicating it into config would create a second source of truth. It + /// arrives on mailRootReady() shortly after startup, so anything composing + /// a path under it has to cope with it being empty for the first moments. + QString m_mailRoot; + + /// A compose request waiting for its message to come back from the worker. + /// + /// The reply family cannot open a composer synchronously: the context is + /// built from the database rather than from the model, so the file path + /// has to be fetched first. This records what to do with the answer. + struct PendingCompose + { + QString messageId; + ComposeContext::Kind kind = ComposeContext::Kind::Reply; + bool quote = true; + bool active = false; + }; + PendingCompose m_pendingCompose; + + /// Every open composer, so the quit path can see them. + /// + /// The QPointer and the closed() signal do DIFFERENT jobs and neither is + /// removable. A composer is WA_DeleteOnClose and deletes itself, so the + /// QPointer is what keeps composersBlockingQuit() from dereferencing a + /// destroyed window: it nulls on destruction. The signal is what lets this + /// list be COMPACTED, since a QPointer that nulled is still an entry and + /// the list would otherwise grow for the session's lifetime. Removing the + /// signal leaks entries; removing the QPointer crashes. + QList<QPointer<ComposeWindow>> m_composers; + /// Confirmed tag mutations not yet known to have reached the mail store. /// /// A count of its own rather than QUndoStack::isClean(), which cannot serve diff --git a/src/markdownrenderer.cpp b/src/markdownrenderer.cpp new file mode 100644 index 0000000..7158981 --- /dev/null +++ b/src/markdownrenderer.cpp @@ -0,0 +1,110 @@ +/* + * 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. + */ + +// cmark-gfm's headers are C and carry no Qt interaction, so the gmime +// include-order rule does not apply here. They still go first, for consistency +// with mimeparser.cpp. +#include <cmark-gfm.h> +#include <cmark-gfm-core-extensions.h> + +#include "markdownrenderer.h" + +#include <QByteArray> + +#include <cstdlib> + +namespace { + +/// The extensions this application enables, by cmark-gfm's own names. +/// +/// `table` is absent deliberately, not by oversight: tables render badly +/// across mail clients regardless of who generates them. `tagfilter` is absent +/// because safe mode (see below) already suppresses raw HTML wholesale, which +/// is the stronger measure. +const char *const kExtensions[] = { "autolink", "strikethrough", "tasklist" }; + +} // namespace + +QString MarkdownRenderer::toHtml(const QString &markdown) +{ + if (markdown.isEmpty()) + return {}; + + // Idempotent, and a hash lookup after the first call. The function-local + // static makes the FIRST call thread-safe: cmark-gfm's registry carries no + // once-guard of its own, so two threads racing the first call would tear + // it. Today's only caller is on the UI thread; this costs nothing and + // removes the trap before a worker-thread caller finds it. + static const bool registered = [] { + cmark_gfm_core_extensions_ensure_registered(); + return true; + }(); + Q_UNUSED(registered) + + // CMARK_OPT_DEFAULT is 0, and CMARK_OPT_SAFE is a NO-OP in cmark-gfm 0.29: + // safe mode has been the default since that release, and the flag is kept + // only for API compatibility with code written against older versions. + // The real requirement is that CMARK_OPT_UNSAFE must never be set. Under + // safe mode a raw <script> block is replaced with an HTML comment + // placeholder, and a link whose scheme is not in the allowed set + // (javascript:, vbscript:, file:, and data: except a few safe image + // types) is replaced with an empty href. Measured against + // cmark-gfm-0.29.0.gfm.13 on 2026-08-20: rendering the same script tag and + // a javascript: link under OPT_DEFAULT alone, under OPT_DEFAULT|OPT_SAFE, + // and under OPT_UNSAFE shows the first two behave identically and + // suppress both, while OPT_UNSAFE leaks both verbatim into the output. + // OPT_SAFE is kept anyway, both as a statement of intent and in case a + // future cmark-gfm release makes it meaningful again; do not read its + // presence as the mechanism actually doing the suppressing. + const int options = CMARK_OPT_DEFAULT | CMARK_OPT_SAFE; + + cmark_parser *parser = cmark_parser_new(options); + if (!parser) + return {}; + + for (const char *name : kExtensions) { + // A missing extension is a broken installation rather than a + // condition to handle: the library was found by CMake. Skipping it + // degrades to plain CommonMark rather than crashing. + if (cmark_syntax_extension *extension = cmark_find_syntax_extension(name)) + cmark_parser_attach_syntax_extension(parser, extension); + } + + const QByteArray utf8 = markdown.toUtf8(); + cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size())); + + cmark_node *document = cmark_parser_finish(parser); + if (!document) { + cmark_parser_free(parser); + return {}; + } + + // The extension list must be passed to the renderer as well as to the + // parser. Passing nullptr here parses the tasklist correctly and then + // renders it as a plain list item, which looks like the extension never + // worked. + char *html = cmark_render_html(document, options, + cmark_parser_get_syntax_extensions(parser)); + const QString result = html ? QString::fromUtf8(html) : QString(); + + free(html); + cmark_node_free(document); + cmark_parser_free(parser); + + return result; +} diff --git a/src/markdownrenderer.h b/src/markdownrenderer.h new file mode 100644 index 0000000..6373fd9 --- /dev/null +++ b/src/markdownrenderer.h @@ -0,0 +1,40 @@ +/* + * 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> + +/// Renders the composer's markdown body into the HTML part's fragment. +/// +/// A namespace of free functions rather than a class: there is no state, and +/// keeping it painter-free and widget-free is what lets the extension +/// configuration be tested on its own. `MessageBuilder` calls this; nothing +/// else does. +namespace MarkdownRenderer { + +/// The markdown source as an HTML fragment: no <html>, <head> or <body>. +/// +/// Three extensions are enabled (autolink, strikethrough, tasklist) and +/// tables are deliberately not. Raw HTML in the input is suppressed by +/// cmark-gfm's safe mode, which is the DEFAULT in 0.29 and is not the +/// CMARK_OPT_SAFE flag (a no-op); see markdownrenderer.cpp for the +/// measurement. The requirement is that CMARK_OPT_UNSAFE is never set. +QString toHtml(const QString &markdown); + +} // namespace MarkdownRenderer diff --git a/src/messagebuilder.cpp b/src/messagebuilder.cpp new file mode 100644 index 0000000..42a0e31 --- /dev/null +++ b/src/messagebuilder.cpp @@ -0,0 +1,407 @@ +/* + * 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. + */ + +// gmime.h pulls in glib's gio headers, which declare a struct field named +// "signals". Qt's <QtCore/qnamespace.h> #defines "signals" to "Q_SIGNALS" +// (unless QT_NO_KEYWORDS is set), so gmime.h must be included before any Qt +// header in this translation unit to avoid a macro collision. +#include <gmime/gmime.h> + +#include "messagebuilder.h" + +#include <QCoreApplication> +#include <QFileInfo> +#include <QMimeDatabase> +#include <QMimeType> +#include <QObject> + +#include "config.h" +#include "markdownrenderer.h" + +namespace { + +/// GMime must be initialised exactly once per process. MimeParser has its own +/// copy of this guard; both are cheap and neither can assume the other ran, +/// since a test may link only one of them. +void ensureGMimeInitialised() +{ + static bool initialised = false; + if (!initialised) { + g_mime_init(); + initialised = true; + } +} + +/// A text part carrying \p text as utf-8, quoted-printable. +/// +/// Deliberately NOT g_mime_text_part_set_text(). Measured 2026-08-20: that +/// function encodes using the charset set at the moment it is CALLED, so the +/// obvious "set the text, then set the charset" order relabels the part without +/// re-encoding it. The result is a part headed charset=utf-8 whose bytes are +/// latin-1 (`Perch=E9`), which looks correct in every header and arrives as +/// mojibake. Building the content stream from the utf-8 bytes directly was +/// measured to produce `Perch=C3=A9` correctly. This user writes Italian, so an +/// accented character is in every message, not an edge case. +GMimePart *makeTextPart(const char *subtype, const QString &text) +{ + GMimePart *part = g_mime_part_new_with_type("text", subtype); + g_mime_object_set_content_type_parameter(GMIME_OBJECT(part), "charset", "utf-8"); + + const QByteArray utf8 = text.toUtf8(); + GMimeStream *stream = g_mime_stream_mem_new_with_buffer(utf8.constData(), + static_cast<size_t>(utf8.size())); + GMimeDataWrapper *wrapper = + g_mime_data_wrapper_new_with_stream(stream, GMIME_CONTENT_ENCODING_DEFAULT); + g_mime_part_set_content(part, wrapper); + g_mime_part_set_content_encoding(part, GMIME_CONTENT_ENCODING_QUOTEDPRINTABLE); + + g_object_unref(wrapper); + g_object_unref(stream); + return part; +} + +/// Sets \p header on \p message to \p addresses, RFC 2047 encoded as utf-8. +/// Returns false and names the offending entry in \p badEntry if any of them +/// could not be parsed as an address. +/// +/// Each entry is passed through internet_address_list_parse() rather than +/// treated as a bare address, because the composer's fields hold whatever the +/// user typed and "Name <addr@example.org>" is the ordinary form. Parsing per +/// entry rather than joining first keeps a comma inside a quoted display name +/// from splitting one recipient into two. +/// +/// An entry that does not parse is a FAILURE, never a skip. The previous +/// version returned void, `continue`d past anything unparseable, and then only +/// wrote the header if the assembled list came out non-empty, so +/// `to = {"not an address at all ((("}` built a message with NO To: header at +/// all and reported success. With `msmtp -t` the recipients come FROM the +/// headers, so that is a message handed to the send command with nobody to +/// deliver to, and a copy filed in Sent that looks sent and reached no one. +/// Dropping one bad entry of several is the same defect wearing a smaller hat: +/// the others are delivered and nothing says which was not. +/// +/// Both the NULL and the zero-length results are treated as failure. Measured +/// 2026-08-20 on GMime 3.2 with a standalone probe, every garbage input tried +/// (`not an address at all (((`, `((((`, `a b c`, `,`, `;`, `()`, `<>`, `` ) +/// returned NULL, and no input was found that produced a non-null empty list. +/// The length check is therefore defensive rather than a path with a fixture +/// behind it: it is kept because the failure it would cover is a silently +/// unaddressed message, and it costs one comparison. Do not read it as +/// documenting observed behaviour, and do not expect a mutation on it to be +/// killed by the suite. +/// +/// Worth knowing for anything built on top of this: GMime is LENIENT, not +/// strict. `garbage` and `""` both parse to a one-entry list. This function +/// rejects what GMime cannot parse at all; it is not an address validator, and +/// a typo that happens to be parseable still goes out. +bool setAddressHeader(GMimeMessage *message, const char *header, const QStringList &addresses, + QString *badEntry); + +/// A message-id in the angle brackets the wire format requires, added if the +/// caller did not supply them. +/// +/// **The brackets are syntax, not decoration, and GMime enforces it by writing +/// an EMPTY HEADER for a bare addr-spec rather than by complaining.** Measured +/// 2026-08-21: `In-Reply-To: current@example.org` emits `In-Reply-To:` with no +/// value, so the reply arrives as an orphan thread in the recipient's client +/// while nothing looks wrong locally. +/// +/// Bracketing lives HERE, in the one function that composes these headers, +/// rather than in each caller. Every source of a message-id in this application +/// hands over a bare one: GMime strips the brackets when MimeParser reads +/// `Message-ID`, and `ComposeContextBuilder::referencesForReply` strips them +/// again from the References chain so the two agree. A convention spread across +/// callers is one a later caller gets wrong, and the failure is invisible +/// without inspecting a sent message. +QString bracketed(const QString &messageId) +{ + const QString id = messageId.trimmed(); + if (id.isEmpty()) + return {}; + if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>'))) + return id; + return QLatin1Char('<') + id + QLatin1Char('>'); +} + +bool setAddressHeader(GMimeMessage *message, const char *header, const QStringList &addresses, + QString *badEntry) +{ + if (addresses.isEmpty()) + return true; + + InternetAddressList *list = internet_address_list_new(); + for (const QString &entry : addresses) { + const QString trimmed = entry.trimmed(); + if (trimmed.isEmpty()) + continue; + const QByteArray utf8 = trimmed.toUtf8(); + InternetAddressList *parsed = internet_address_list_parse(nullptr, utf8.constData()); + const bool parsedNothing = !parsed || internet_address_list_length(parsed) == 0; + if (parsedNothing) { + if (parsed) + g_object_unref(parsed); + g_object_unref(list); + *badEntry = trimmed; + return false; + } + internet_address_list_append(list, parsed); + g_object_unref(parsed); + } + + if (internet_address_list_length(list) > 0) { + GMimeFormatOptions *format = g_mime_format_options_get_default(); + char *rendered = internet_address_list_to_string(list, format, TRUE); + if (rendered) { + g_mime_object_set_header(GMIME_OBJECT(message), header, rendered, "utf-8"); + g_free(rendered); + } + } + g_object_unref(list); + return true; +} + +} // namespace + +namespace MessageBuilder { + +Result build(const OutgoingMessage &message, const Account &account) +{ + Result result; + + // Config::account() returns a DEFAULT-CONSTRUCTED Account for an unknown + // key rather than reporting an error, so an account reached by a stale or + // mistyped key arrives here looking like a valid one with empty fields. + // Building from it would produce a message with an empty From: silently + // malformed mail handed to the send command as though it were fine. + if (account.address.trimmed().isEmpty()) { + result.error = QObject::tr("The account %1 has no address configured, so no message " + "can be sent from it.") + .arg(account.key); + return result; + } + + // Attachments are checked HERE rather than when the file was attached: a + // file can vanish in between, and a message missing the thing it was + // written to carry must never reach the send command. Checked before + // anything is allocated, so the failure path frees nothing. + // + // isFile() is load-bearing and not tidiness. A DIRECTORY reports + // exists=1 and isReadable=1, opening one read-only is legal, and GMime's + // base64 encoder then loops on a read() returning EISDIR without ever + // advancing or erroring: measured 2026-08-20 with strace at 2,169,821 + // failed reads in twenty seconds and still going, so build() never + // returns. It runs synchronously from autosave on the GUI thread, so + // dragging a folder into a composer froze the whole application with the + // draft unrecoverable. Device nodes and FIFOs block or read forever the + // same way, and isFile() excludes those too. + for (const QString &path : message.attachments) { + const QFileInfo info(path); + if (!info.exists() || !info.isFile() || !info.isReadable()) { + result.error = QObject::tr("The attachment %1 is missing or unreadable.") + .arg(info.fileName().isEmpty() ? path : info.fileName()); + return result; + } + } + + ensureGMimeInitialised(); + + GMimeMessage *mime = g_mime_message_new(TRUE); + + const QByteArray fromName = account.name.toUtf8(); + const QByteArray fromAddress = account.address.toUtf8(); + g_mime_message_add_mailbox(mime, GMIME_ADDRESS_TYPE_FROM, + account.name.isEmpty() ? nullptr : fromName.constData(), + fromAddress.constData()); + + // A recipient the user typed and this cannot understand STOPS the send, + // exactly as a missing attachment does, rather than quietly not being + // written. See setAddressHeader for what the silent version cost. + const struct { const char *header; const QStringList &values; } fields[] = { + {"To", message.to}, + {"Cc", message.cc}, + // Bcc is written into the bytes deliberately, and this is two separate + // decisions rather than one. + // + // On transmission: the documented send command is `msmtp -t`, which + // reads its recipients FROM the headers and strips Bcc itself before + // sending, so recipients never see the list. Omitting it here would + // mean blind recipients never receive the message at all, silently. If + // sending ever passes recipients as arguments instead, this entry must + // go with it. + // + // At rest: one built message serves three consumers, so the SENT COPY + // and any autosaved DRAFT are stored in the Maildir with the Bcc list + // in plaintext, and mbsync syncs those to the IMAP server where they + // are visible to anyone with account access. That is a separate + // exposure from transmission and it is accepted knowingly, not + // overlooked. Do not "fix" it by stripping Bcc here: that breaks blind + // delivery silently, which is worse. + {"Bcc", message.bcc}, + }; + for (const auto &field : fields) { + QString badEntry; + if (!setAddressHeader(mime, field.header, field.values, &badEntry)) { + g_object_unref(mime); + result.error = QObject::tr("%1 is not an address this can send to.").arg(badEntry); + return result; + } + } + + // The explicit "utf-8". Measured 2026-08-20: with NULL here GMime encodes + // the subject as iso-8859-1 (=?iso-8859-1?B?...?=). + const QByteArray subject = message.subject.toUtf8(); + g_mime_message_set_subject(mime, subject.constData(), "utf-8"); + + // Both headers are bracketed HERE rather than by the caller. See bracketed() + // for why, and for what a bare id costs. + const QString inReplyTo = bracketed(message.inReplyTo); + if (!inReplyTo.isEmpty()) { + const QByteArray value = inReplyTo.toUtf8(); + g_mime_object_set_header(GMIME_OBJECT(mime), "In-Reply-To", value.constData(), "utf-8"); + } + QStringList references; + for (const QString &id : message.references) { + const QString bracketedId = bracketed(id); + // An empty entry contributes nothing rather than a stray "<>": the + // References header is a run of ids, and one malformed entry is enough + // for a strict parser to discard the whole chain. + // + // Defensive rather than a path with a fixture behind it, like the + // length check in setAddressHeader above: referencesForReply() already + // drops empty ids, so a mutation on this line SURVIVES the suite. + // Measured 2026-08-21. Kept because it costs one comparison and the + // failure it covers is a silently broken thread. + if (!bracketedId.isEmpty()) + references.append(bracketedId); + } + if (!references.isEmpty()) { + const QByteArray value = references.join(QLatin1Char(' ')).toUtf8(); + g_mime_object_set_header(GMIME_OBJECT(mime), "References", value.constData(), "utf-8"); + } + + // Measured 2026-08-20: GMime generates neither Date nor Message-ID unless + // asked. A message without a Message-ID cannot be threaded by anything that + // receives it, this application's own index of the sent copy included. + GDateTime *now = g_date_time_new_now_local(); + g_mime_message_set_date(mime, now); + g_date_time_unref(now); + + const QString domain = account.address.section(QLatin1Char('@'), 1); + const QByteArray domainUtf8 = (domain.isEmpty() ? QStringLiteral("localhost") : domain).toUtf8(); + // Held locally rather than written into `result` here. Every failure below + // would otherwise have to remember to clear it, which is a two-place + // invariant the next early return forgets; it is assigned once, beside the + // bytes, on the one path that succeeds. + QString messageId; + char *generatedId = g_mime_utils_generate_message_id(domainUtf8.constData()); + if (generatedId) { + g_mime_message_set_message_id(mime, generatedId); + messageId = QString::fromUtf8(generatedId); + g_free(generatedId); + } + + // The markdown SOURCE is the plain part, never a stripped-of-syntax + // rewrite: `**bold**` reads as emphasis, and rewriting it would mean a + // second renderer whose output could disagree with the HTML one. + GMimeObject *body = GMIME_OBJECT(makeTextPart("plain", message.markdownBody)); + + if (message.sendHtml) { + GMimePart *html = makeTextPart("html", MarkdownRenderer::toHtml(message.markdownBody)); + GMimeMultipart *alternative = g_mime_multipart_new_with_subtype("alternative"); + // Least-rich FIRST. A client renders the LAST alternative it + // understands, so a reversed order shows the markdown source everywhere + // and the rendered part is never seen. + g_mime_multipart_add(alternative, body); + g_mime_multipart_add(alternative, GMIME_OBJECT(html)); + g_object_unref(body); + g_object_unref(html); + body = GMIME_OBJECT(alternative); + } + + if (!message.attachments.isEmpty()) { + GMimeMultipart *mixed = g_mime_multipart_new_with_subtype("mixed"); + // The body goes in FIRST, so the wrapper NESTS it rather than standing + // beside it. Beside it, a client shows the alternatives as attachments + // and the message reads as empty. + g_mime_multipart_add(mixed, body); + g_object_unref(body); + + QMimeDatabase mimeDb; + for (const QString &path : message.attachments) { + const QFileInfo info(path); + const QMimeType type = mimeDb.mimeTypeForFile(info); + const QByteArray typeName = type.name().toUtf8(); + + GMimeContentType *contentType = + g_mime_content_type_parse(nullptr, typeName.isEmpty() + ? "application/octet-stream" + : typeName.constData()); + GMimePart *part = g_mime_part_new(); + if (contentType) { + g_mime_object_set_content_type(GMIME_OBJECT(part), contentType); + g_object_unref(contentType); + } + + GMimeStream *stream = g_mime_stream_file_open(path.toLocal8Bit().constData(), + "r", nullptr); + if (!stream) { + // Existence was checked above, so reaching here means the file + // went away between the check and the read. Fail rather than + // send a message with a hole in it. + g_object_unref(part); + g_object_unref(mixed); + g_object_unref(mime); + result.error = QObject::tr("The attachment %1 could not be read.") + .arg(info.fileName()); + return result; + } + GMimeDataWrapper *wrapper = + g_mime_data_wrapper_new_with_stream(stream, GMIME_CONTENT_ENCODING_DEFAULT); + g_mime_part_set_content(part, wrapper); + g_mime_part_set_content_encoding(part, GMIME_CONTENT_ENCODING_BASE64); + g_object_unref(wrapper); + g_object_unref(stream); + + const QByteArray filename = info.fileName().toUtf8(); + g_mime_part_set_filename(part, filename.constData()); + g_mime_object_set_disposition(GMIME_OBJECT(part), "attachment"); + + g_mime_multipart_add(mixed, GMIME_OBJECT(part)); + g_object_unref(part); + } + body = GMIME_OBJECT(mixed); + } + + g_mime_message_set_mime_part(mime, body); + g_object_unref(body); + + GMimeFormatOptions *format = g_mime_format_options_get_default(); + char *rendered = g_mime_object_to_string(GMIME_OBJECT(mime), format); + if (rendered) { + result.bytes = QByteArray(rendered); + result.messageId = messageId; + g_free(rendered); + } else { + result.error = QObject::tr("The message could not be assembled."); + } + + g_object_unref(mime); + return result; +} + +} // namespace MessageBuilder diff --git a/src/messagebuilder.h b/src/messagebuilder.h new file mode 100644 index 0000000..f9de277 --- /dev/null +++ b/src/messagebuilder.h @@ -0,0 +1,59 @@ +/* + * 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 <QByteArray> +#include <QString> + +#include "types.h" + +struct Account; + +/// Turns an OutgoingMessage into the RFC822 bytes that get sent. +/// +/// ONE built message serves three consumers: the autosaved draft, the bytes on +/// the send command's stdin, and the sent copy. A draft is therefore +/// byte-identical to what would be sent. +/// +/// GMime rather than assembling RFC822 by string. The alternative means +/// reimplementing RFC 2047 header encoding, quoted-printable for accented +/// bodies, boundary uniqueness and line-length limits. This user writes +/// Italian; a body containing an accented character is every message, and a +/// bug there produces mail that looks correct locally and arrives as mojibake. +namespace MessageBuilder { + +struct Result +{ + QByteArray bytes; ///< The complete message. Empty on failure. + QString error; ///< Empty on success. + QString messageId; ///< The generated Message-ID, for the caller's records. + + bool ok() const { return error.isEmpty(); } +}; + +/// Builds \p message as sent from \p account. +/// +/// Fails, rather than sending a partial message, when an attachment named in +/// the message no longer exists. That is checked HERE, at build time, rather +/// than when the file was attached: a file can vanish in between, and the +/// failure must stop the send rather than produce a message missing the thing +/// it was written to carry. +Result build(const OutgoingMessage &message, const Account &account); + +} // namespace MessageBuilder diff --git a/src/messagesender.cpp b/src/messagesender.cpp new file mode 100644 index 0000000..f336028 --- /dev/null +++ b/src/messagesender.cpp @@ -0,0 +1,197 @@ +/* + * 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 "messagesender.h" + +MessageSender::MessageSender(QObject *parent) + : QObject(parent) +{ + // Separate channels, unlike MailSync's MergedChannels: there is no log + // pane to fill here, and stderr alone is what a failure has to report. + // Merging them would put the command's ordinary chatter into the error + // message shown for a rejected send. + m_process.setProcessChannelMode(QProcess::SeparateChannels); + + connect(&m_process, &QProcess::finished, + this, &MessageSender::handleFinished); + connect(&m_process, &QProcess::errorOccurred, + this, &MessageSender::handleError); +} + +MessageSender::~MessageSender() +{ + if (m_process.state() == QProcess::NotRunning) + return; + + // A send is a live SMTP conversation and abandoning one has a genuinely + // unknown outcome, so give the command a bounded chance to finish rather + // than killing it outright. Measured: without this, a one-second command + // destroyed 100ms in is killed and its work does not complete, announced + // only by a Qt warning on stderr. With it, the same command completes and + // the destructor costs the ~1s the command actually needed. + // + // The write channel is closed first because the command may still be + // reading: a command blocked on stdin would otherwise never reach EOF and + // would burn the whole timeout for no reason. + m_process.closeWriteChannel(); + if (m_process.waitForFinished(kShutdownWaitMs)) + return; + + // Still running. A destructor cannot block a quitting application forever, + // so the process is killed deliberately here rather than by ~QProcess. + // + // NOTHING IS EMITTED. The outcome after a kill is unknown: the message may + // have been fully delivered, partially delivered, or not sent at all, and + // this class reports two outcomes only. Emitting finished(false, ...) would + // report "not sent" for a message that may well have been, which is the + // mailsync.sh mistake pointing the other way. Emitting finished(true, ...) + // would be worse. A caller that must know has to keep this object alive + // until finished() arrives. + // + // Claiming the report BEFORE the kill is what makes that true, and it is + // not optional: kill() makes QProcess deliver finished(CrashExit), which + // reaches handleFinished and would emit exactly the untruthful "not sent" + // this comment forbids. Measured, by a test that failed against the + // version without these two lines. This is also the one place m_reported + // does live work, rather than the defence-in-depth it is on the signal + // paths. + m_reported = true; + m_process.kill(); + m_process.waitForFinished(kShutdownWaitMs); +} + +bool MessageSender::isRunning() const +{ + return m_process.state() != QProcess::NotRunning; +} + +bool MessageSender::send(const QString &command, const QByteArray &bytes) +{ + if (command.trimmed().isEmpty() || isRunning()) + return false; + + // splitCommand gives an argument list; running through a shell would make + // every recipient address, display name and config value a potential + // injection point. QProcess hands the list to execve, so a `;` or a + // `$(...)` in the configured command is a literal argument with nothing to + // interpret it. Note that splitCommand strips DOUBLE quotes only. + // + // Nothing from the message reaches the argument list at all: the command + // reads its recipients from the message's own headers, which is what `-t` + // means in the documented example. + const QStringList parts = QProcess::splitCommand(command); + if (parts.isEmpty()) + return false; + + m_command = command; + m_reported = false; + + m_process.setProgram(parts.first()); + m_process.setArguments(parts.mid(1)); + + // Deliberately no waitForStarted(): this runs on the GUI thread and the + // interface must stay responsive while a send is in flight. A failed + // launch arrives via errorOccurred(FailedToStart) instead, which QProcess + // emits INSTEAD OF finished() rather than before it (measured). + m_process.start(); + + // Written after start() and before the process has necessarily launched, + // which is safe: QProcess buffers and drains as the reader consumes. + // Measured with a 320KB payload against a `cat` stub, which arrived + // byte-identical, so a message with an attachment does not deadlock on the + // 64KB pipe buffer. + m_process.write(bytes); + + // The message goes on stdin and the channel is closed, so a command + // reading to EOF terminates. Without closeWriteChannel() a command like + // `cat` waits forever and the popup never leaves its Sending stage. + m_process.closeWriteChannel(); + + return true; +} + +void MessageSender::handleFinished(int exitCode, QProcess::ExitStatus status) +{ + // errorOccurred may already have reported this failure. Reporting twice + // would close the popup and then act on a second result. + // + // This guard IS load-bearing, on exactly one path: the destructor sets + // m_reported before kill(), because kill() makes QProcess deliver + // finished(CrashExit) and without the flag this handler would emit a + // "not sent" for a message whose fate is genuinely unknown. A test fails + // against its removal. + // + // On the two signal paths it is defence in depth and currently cannot + // fire: handleError is filtered to FailedToStart, and FailedToStart is + // never followed by finished() (measured). An instrumented run of the + // whole suite recorded zero hits there, including on the crash and + // write-error paths that DO emit both signals. It stays because the day + // someone widens handleError to report another error, the double report is + // silent and costs a duplicate sent copy. + if (m_reported) + return; + m_reported = true; + + // The exit status is the only authority. Nothing is inferred from what the + // command printed: mailsync.sh records what a wrong answer here costs, and + // a send reported as succeeding files a sent copy for a message that never + // left the machine. + const bool sent = status == QProcess::NormalExit && exitCode == 0; + if (sent) { + emit finished(true, QString()); + return; + } + + // Exit 75 is deliberately NOT special. See the header. + QString error = QString::fromUtf8(m_process.readAllStandardError()).trimmed(); + if (error.isEmpty()) { + // A failure with a blank explanation gives the user nothing to act on, + // so the status stands in for the reason the command did not give. + error = status == QProcess::CrashExit + ? tr("The send command crashed.") + : tr("The send command exited with status %1 and said nothing.") + .arg(exitCode); + } + emit finished(false, error); +} + +void MessageSender::handleError(QProcess::ProcessError error) +{ + // QProcess emits errorOccurred(FailedToStart) INSTEAD OF finished(), so + // without this the caller waits forever. Measured on Qt 6.11 for both a + // missing binary and a non-executable file: one errorOccurred, no + // finished(). + // + // Every other error IS followed by finished() and is left to it, which is + // not merely tidiness. A command that exits without draining a large stdin + // emits errorOccurred(WriteError) and then finished() with the command's + // real exit code and its real stderr; reporting the write error here would + // replace the server's own rejection message with a plumbing detail, and + // reporting it as well as finished() would deliver two results for one + // message. + if (error != QProcess::FailedToStart) + return; + if (m_reported) + return; + m_reported = true; + + emit finished(false, + tr("The send command '%1' could not be started. Check that " + "the path is correct and the file is executable.") + .arg(m_command)); +} diff --git a/src/messagesender.h b/src/messagesender.h new file mode 100644 index 0000000..86dde68 --- /dev/null +++ b/src/messagesender.h @@ -0,0 +1,164 @@ +/* + * 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 <QObject> +#include <QProcess> +#include <QString> + +/// Runs an account's send_command with the message on stdin. +/// +/// EXACTLY TWO OUTCOMES: sent, or not sent with a reason. Exit code 75 has no +/// special meaning here, unlike in the sync path. Item 125 is open precisely +/// because mailsync.sh treats 75 as neither success nor failure and hangs on +/// it; that exists because the script contends for a lock and there is no lock +/// here. Recorded so the two paths are not later "harmonised". +/// +/// **The exit status is the only authority on whether a message was sent.** +/// This is the same rule assets/mailsync.sh exists to honour, and the same +/// class of bug is available here: a sender that reported success on anything +/// other than exit 0 would file a sent copy and close the composer for a +/// message that never left the machine. Nothing is derived from the command's +/// output, which belongs to whatever the user installed behind send_command. +/// +/// **No shell, ever.** The command is a config value and is split into an +/// argument list with QProcess::splitCommand, then handed to QProcess, which +/// calls execve directly. A `;`, `&&`, `$(...)` or a backtick in the +/// configured string therefore arrives as a literal argument with nothing to +/// interpret it. Note that splitCommand understands DOUBLE quotes only: +/// `-a 'my acct'` splits into three arguments, so a path or an argument +/// containing a space must be written with double quotes. Measured, not +/// assumed. +/// +/// **No message content ever reaches the argument list.** The bytes go on +/// stdin and only on stdin; the command reads its recipients from the +/// message's own headers, which is what `-t` means in the documented example. +/// A recipient address or a display name therefore cannot become an argument +/// however it is spelled. +/// +/// This is the outbox seam. An outbox is built by calling this from a drain +/// loop; nothing in the composer would need to change. +/// +/// Nothing here blocks the GUI thread DURING a send. send() hands the process +/// to the event loop and returns; there is no waitForStarted() and no +/// waitForFinished() on that path, so a command that hangs leaves the +/// interface responsive and the caller waiting on finished(). Timing a hung +/// command out is deliberately NOT this class's job: a timeout here would kill +/// a slow but working send. The one place this class does block is its +/// destructor, and that is the subject of the next paragraph. +/// +/// **Destruction mid-send waits, briefly, and then kills.** A send is a live +/// SMTP conversation, so the outcome of abandoning one is genuinely unknown: +/// the message may be fully delivered, partially delivered, or not sent at +/// all. Measured with a one-second command destroyed 100ms in: plain +/// destruction returns in 100ms, kills the child, and the work does NOT +/// complete, announced by nothing but a `QProcess: Destroyed while process is +/// still running` warning on stderr. That is the mailsync.sh failure in a new +/// place, an unknown real outcome reported as a definite one, and it is +/// reachable by closing the composer with the window manager's X button while +/// a send is in flight. +/// +/// So the destructor waits up to kShutdownWaitMs for the command to finish on +/// its own, which is the outcome that makes the report truthful: the same +/// measurement with a bounded wait completes the child and costs only the +/// ~1s the command actually needed. A command still running after that is +/// killed, because a destructor cannot block a quitting application forever. +/// +/// **No finished() is emitted from the destructor, in either branch, and that +/// is deliberate rather than an omission.** After a kill the outcome is +/// unknown, and this class reports two outcomes only; inventing a third by +/// guessing would be the exact lie the rest of this header is built to avoid. +/// After a successful late finish the emit would reach handlers on a +/// half-destroyed caller. A caller that must know the result has to keep the +/// sender alive until finished() arrives, which is what refusing to close a +/// composer mid-send would express. +/// +/// **There is no cancel(), and the caller does not have one either.** An +/// earlier revision of this comment deferred cancellation to "the caller's +/// popup", which overstated what exists: SendDialog offers an undo BEFORE the +/// send is committed and none after, by an explicit design decision that a +/// post-commit cancel is worse than either clean outcome. If a real cancel is +/// ever wanted it belongs HERE, killing the process and emitting one +/// finished(false, ...) through m_reported, which is the shape that flag +/// already has. It is not built now, and this header does not promise it. +class MessageSender : public QObject +{ + Q_OBJECT + +public: + explicit MessageSender(QObject *parent = nullptr); + + /// Waits briefly for an in-flight send, then kills it. See the class + /// comment: this is the one blocking call in the class, and it emits + /// nothing. + ~MessageSender() override; + + /// How long the destructor gives an in-flight command to finish on its + /// own before killing it. Long enough for a local MTA handing off to a + /// queue, short enough not to hang a quitting application. + static constexpr int kShutdownWaitMs = 5000; + + /// Starts \p command with \p bytes on stdin. + /// + /// Returns false without emitting anything when the command is empty or + /// only whitespace, when it splits to nothing, or when a send is already + /// running. A true return means the process was handed to the event loop, + /// NOT that it launched: a missing or non-executable binary surfaces + /// asynchronously through finished(false, ...), exactly as MailSync + /// documents. + bool send(const QString &command, const QByteArray &bytes); + + bool isRunning() const; + +signals: + /// \p error is empty on success and carries the command's stderr, or a + /// description of why it could not start, on failure. + /// + /// EMITTED exactly once per accepted send, and the distinction between + /// emitted and RECEIVED is the whole of this paragraph. QProcess can report + /// both an error and a finish for one run (measured: a command that exits + /// without draining a large stdin emits errorOccurred(WriteError) and then + /// finished()), and m_reported collapses that to one emit. + /// + /// **m_reported guards the emit, not the receivers, and a caller can still + /// see one result twice.** A MessageSender is normally a long-lived member + /// reused for every send, so a caller that connects INSIDE its send path + /// adds a permanent connection each time: send, fail, correct the + /// recipient, send again, and the second result runs BOTH lambdas. The + /// first still holds the first message's bytes, so it files a sent copy of + /// the wrong message and acts on a dialog it already destroyed. That is + /// precisely the harm this signal's contract exists to prevent, arriving + /// by the one route no guard inside this class can cover. + /// + /// A caller connecting per-send must therefore pass + /// `Qt::SingleShotConnection` (Qt 6.0+; this project is on 6.11), which + /// disconnects the moment the lambda runs. Connecting ONCE in the caller's + /// constructor and keeping the per-send state in members is the other + /// correct shape. What is not correct, and what reads as permitted if this + /// paragraph is skipped, is a bare connect() next to a send() call. + void finished(bool sent, const QString &error); + +private: + void handleFinished(int exitCode, QProcess::ExitStatus status); + void handleError(QProcess::ProcessError error); + + QProcess m_process; + QString m_command; + bool m_reported = false; +}; diff --git a/src/messageview.cpp b/src/messageview.cpp index 469d148..5682858 100644 --- a/src/messageview.cpp +++ b/src/messageview.cpp @@ -416,6 +416,17 @@ MessageView::MessageView(QWidget *parent) staleRow->addStretch(); m_staleBar->hide(); + // Receive-only ribbon (item 123). Hidden until a message from an account + // with no send_command is displayed. + m_receiveOnlyRibbon = new QLabel(this); + m_receiveOnlyRibbon->setObjectName(QStringLiteral("receiveOnlyRibbon")); + // Qt::PlainText explicitly. The account key comes from configuration + // rather than from a stranger, but a QLabel guesses under Qt::AutoText and + // this is the same protection MessageDetailsDialog states on every value. + m_receiveOnlyRibbon->setTextFormat(Qt::PlainText); + m_receiveOnlyRibbon->setWordWrap(true); + m_receiveOnlyRibbon->hide(); + m_attachmentBar = new QWidget(this); m_attachmentBar->setObjectName(QStringLiteral("attachmentBar")); new QHBoxLayout(m_attachmentBar); @@ -442,6 +453,7 @@ MessageView::MessageView(QWidget *parent) auto *layout = new QVBoxLayout(this); layout->addLayout(headerRow); layout->addLayout(blockedRow); + layout->addWidget(m_receiveOnlyRibbon); layout->addWidget(m_staleBar); layout->addWidget(m_view, 1); layout->addWidget(m_attachmentBar); @@ -1234,6 +1246,23 @@ void MessageView::saveAttachment(const Attachment &attachment) emit statusMessage(tr("Saved %1").arg(written)); } +void MessageView::setReceiveOnlyAccount(const QString &accountKey) +{ + if (accountKey.isEmpty()) { + m_receiveOnlyRibbon->hide(); + return; + } + + // Names the account AND the key to add. A ribbon saying only "you cannot + // reply" leaves the user with nothing to do about it, and the shape is + // expressed by omission, so there is no setting to go and look for. + m_receiveOnlyRibbon->setText( + tr("This account is receive-only. Add send_command to [account.%1] " + "to send from it.") + .arg(accountKey)); + m_receiveOnlyRibbon->show(); +} + void MessageView::setStaleThread(const QString &threadId, const QString &messageId) { diff --git a/src/messageview.h b/src/messageview.h index 3cc1604..044bded 100644 --- a/src/messageview.h +++ b/src/messageview.h @@ -128,6 +128,15 @@ public: /// Tags of the thread on display, shown as chips along the bottom. void setTags(const QStringList &tags); + /// Shows or hides the receive-only explanation, naming \p accountKey. + /// An empty key hides it. + /// + /// A WIDGET in this layout, never markup inside the web view. Composing + /// HTML from configuration into the one document that renders input from + /// strangers is the wrong direction, and the header row is already a + /// widget for the same reason. + void setReceiveOnlyAccount(const QString &accountKey); + /// The full headers of every message in the thread, read-only. Also /// reachable from the button beside the header; public so the window's /// message_details action can call it. @@ -391,6 +400,7 @@ private: QLabel *m_headerLabel = nullptr; QLabel *m_blockedLabel = nullptr; + QLabel *m_receiveOnlyRibbon = nullptr; QPushButton *m_loadRemoteButton = nullptr; /// The stale-thread notice and the thread it offers to restore. diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp index 2782a4a..c1198b8 100644 --- a/src/mimeparser.cpp +++ b/src/mimeparser.cpp @@ -412,9 +412,11 @@ ParsedMessage MimeParser::parse(const QString &filePath) const out.subject = QString::fromUtf8( g_mime_message_get_subject(message) ?: ""); out.from = headerText(message, "From"); + out.replyTo = headerText(message, "Reply-To"); out.to = headerText(message, "To"); out.cc = headerText(message, "Cc"); out.date = headerText(message, "Date"); + out.references = headerText(message, "References"); out.messageId = QString::fromUtf8( g_mime_message_get_message_id(message) ?: ""); diff --git a/src/mimeparser.h b/src/mimeparser.h index da54434..64c4585 100644 --- a/src/mimeparser.h +++ b/src/mimeparser.h @@ -119,11 +119,27 @@ struct ParsedMessage QString subject; QString from; + + /// Where the author asked for replies to go, raw and undecoded-into-parts. + /// + /// Takes precedence over `from` when building a reply (RFC 5322 3.6.2). + /// Empty on the great majority of mail; a mailing list is the common case + /// that sets it, and honouring it is what keeps a list reply on the list + /// rather than on a person who never asked to be written to directly. + QString replyTo; + QString to; QString cc; QString date; QString messageId; + /// The raw References header, a whitespace-separated run of <message-ids>. + /// + /// Carried so a reply can extend the chain. Without it the reply appears + /// as an orphan thread in the recipient's client, which is the whole + /// reason the header exists. + QString references; + QString plainBody; QString htmlBody; diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index d0274cd..fca0a5a 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -20,16 +20,15 @@ #include <notmuch.h> -#include <QCoreApplication> #include <QDateTime> #include <QDir> #include <QDirIterator> #include <QFileInfo> -#include <QHostInfo> #include <QSet> #include <cstdlib> +#include "maildirname.h" #include "mimeparser.h" #include "nmraii.h" @@ -541,8 +540,17 @@ void NotmuchWorker::loadThreadTree(const QString &threadId, void NotmuchWorker::loadMessage(const QString &messageId, quint64 generation) { - if (!openReadOnly()) + // Every failure below emits an EMPTY result as well as its error, and that + // is a contract rather than tidiness. The bottom of this function already + // said so ("emitted even when empty, so the UI's handler runs"), but the + // three failure paths returned silently and broke it. A caller that arms + // state on this request and disarms it on the reply then waits for ever: + // MainWindow's compose path did exactly that, and a request left armed + // hijacks a later pane load for the same message. + if (!openReadOnly()) { + emit messageLoaded({}, generation); return; + } // id: is an exact-match prefix, and the id is quoted because a message id // can legitimately contain characters notmuch's parser would otherwise read @@ -552,6 +560,7 @@ void NotmuchWorker::loadMessage(const QString &messageId, quint64 generation) if (!nmQuery) { emit errorOccurred( QStringLiteral("Cannot load message %1").arg(messageId)); + emit messageLoaded({}, generation); return; } @@ -560,6 +569,7 @@ void NotmuchWorker::loadMessage(const QString &messageId, quint64 generation) != NOTMUCH_STATUS_SUCCESS) { emit errorOccurred( QStringLiteral("Cannot search message %1").arg(messageId)); + emit messageLoaded({}, generation); return; } NmMessages messages(rawMessages); @@ -687,63 +697,6 @@ void NotmuchWorker::applyTags(const TagChange &change) emit tagsApplied(change); } -namespace { - -/// A fresh Maildir filename for a message being moved between folders, -/// preserving only its `:2,<flags>` suffix. -/// -/// mbsync's manual is explicit about why this exists, under "the more -/// efficient default UID mapping scheme": "it is important that the MUA -/// renames files when moving them between Maildir folders", and "the general -/// expectation is that a completely new filename is generated as if the -/// message was new". -/// -/// The `,U=<n>` infix mbsync writes is its per-folder IMAP UID. Carrying it -/// into another folder makes it a claim about a folder the file is no longer -/// in; moving a message out and back then reinserts a UID the server has -/// since reassigned, and mbsync refuses the folder with `Maildir error: -/// duplicate UID`. Measured on real mail, four collisions in one folder from -/// a single move-and-restore. -/// -/// The FLAGS are kept, deliberately, and that is not a contradiction of -/// "as if the message was new". They record seen, flagged and replied, and -/// `maildir.synchronize_flags` is true, so notmuch reads them back as tags: -/// dropping them would mark every deleted message unread and lose Important -/// on the way to the trash. Only the unique part is regenerated. -QString freshMaildirName(const QString &oldName) -{ - // The `:2,` suffix, when there is one. `info` is everything from the - // separator on, so an empty-flag `:2,` is preserved as faithfully as - // `:2,FS`. - QString info; - const int sep = oldName.indexOf(QStringLiteral(":2,")); - if (sep >= 0) - info = oldName.mid(sep); - - // The conventional left-to-right unique part: time, a per-process counter, - // the pid, the host. The counter is what makes two messages moved in the - // same second distinct, which a timestamp alone does not guarantee. - static quint64 counter = 0; - const qint64 now = QDateTime::currentSecsSinceEpoch(); - const QString host = QHostInfo::localHostName().isEmpty() - ? QStringLiteral("localhost") - : QHostInfo::localHostName(); - - return QStringLiteral("%1.M%2P%3Q%4.%5%6") - .arg(now) - .arg(QDateTime::currentMSecsSinceEpoch() % 1000) - .arg(QCoreApplication::applicationPid()) - .arg(++counter) - // A `/` or a `:` in a hostname would break the path or the flag - // separator. Neither is legal in a hostname, so this is belt and - // braces rather than a known case. - .arg(QString(host).replace(QLatin1Char('/'), QLatin1Char('_')) - .replace(QLatin1Char(':'), QLatin1Char('_'))) - .arg(info); -} - -} // namespace - void NotmuchWorker::moveMessages(const QStringList &messageIds, const QString &destFolder) { @@ -822,11 +775,11 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds, continue; } - // A FRESH name, never the old one. See freshMaildirName(): carrying + // A FRESH name, never the old one. See MaildirName::fresh(): carrying // the `,U=` infix across a folder boundary is what produced // `Maildir error: duplicate UID` on real mail. const QString to = destDir + QLatin1Char('/') - + freshMaildirName(QFileInfo(from).fileName()); + + MaildirName::fresh(QFileInfo(from).fileName()); if (!QFile::rename(from, to)) { emit errorOccurred(QStringLiteral("Cannot move %1 to %2") @@ -1076,6 +1029,25 @@ void NotmuchWorker::requestMessageCounts(const QStringList &queries, emit messageCountsReady(counts, generation); } +void NotmuchWorker::requestMailRoot() +{ + if (!openReadOnly()) { + // Answered anyway, with an empty root. A consumer waiting for this + // signal to enable something would otherwise wait for ever on a + // database that cannot be opened, which is the same silent stall + // loadMessage() emits an empty result to avoid. + emit mailRootReady(QString()); + return; + } + + // mailRootOf(), never notmuch_database_get_path(). Item 124: under a split + // config the latter names the INDEX directory, and a draft or a sent copy + // composed from it is written into the Xapian tree. + const QString root = mailRootOf(m_db); + emit mailRootReady(root.isEmpty() ? QString() + : QDir(root).absolutePath()); +} + void NotmuchWorker::requestFolders() { if (!openReadOnly()) diff --git a/src/notmuchworker.h b/src/notmuchworker.h index 9932e59..8ed878f 100644 --- a/src/notmuchworker.h +++ b/src/notmuchworker.h @@ -223,6 +223,20 @@ public slots: /// source of truth the design refuses. void requestFolders(); + /// The Maildir root, for whatever has to compose a path under it. + /// + /// This class owns the only database handle, and the root is a property of + /// the DATABASE rather than of config: notmuch can split the index from + /// the mail with `mail_root` and `path` as separate keys, so there is no + /// config key the UI could read instead. Item 124 records what the wrong + /// accessor costs. `notmuch_database_get_path()` returns the INDEX + /// directory under that layout, and a destination composed from it writes + /// into the Xapian tree. + /// + /// Requested at startup beside requestAllTags(), and answered once. The + /// root does not change while the application runs. + void requestMailRoot(); + signals: void threadsReady(const QVector<ThreadSummary> &threads, quint64 generation); void queryFinished(int totalThreads, quint64 generation); @@ -288,6 +302,12 @@ signals: /// asks once when its dialog opens. void foldersReady(const QStringList &folders); + /// The Maildir root, absolute. No generation: it is a property of the + /// database rather than of any query, so a late answer is still the right + /// one. Empty when the database could not be opened, which a consumer must + /// treat as "cannot compose a path yet" rather than as the root being "". + void mailRootReady(const QString &mailRoot); + void errorOccurred(const QString &message); private: diff --git a/src/senddialog.cpp b/src/senddialog.cpp new file mode 100644 index 0000000..4a36b7b --- /dev/null +++ b/src/senddialog.cpp @@ -0,0 +1,318 @@ +/* + * 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 "senddialog.h" + +#include <QDateTime> +#include <QFontMetrics> +#include <QHBoxLayout> +#include <QCloseEvent> +#include <QKeyEvent> +#include <QLabel> +#include <QPushButton> +#include <QTimer> +#include <QVBoxLayout> + +#include "busyindicator.h" + +namespace { + +// How often the countdown repaints: smooth enough for a draining bar without +// being a busy loop. It is also the resolution of the countdown itself, since +// tick() subtracts exactly this much rather than consulting a clock. Two +// consequences, both deliberate: a delay that is not a multiple of 100 rounds +// UP to one (250 runs for 300ms), and timer slack accumulates rather than +// being corrected against a clock. Drift is irrelevant at this scale, where +// the number is a courtesy pause and nothing downstream measures it. +constexpr int kTickMs = 100; + +// How long the refused-dismissal hint holds the status label. Longer than a +// tick, or the countdown would overwrite it before it could be read and the +// refusal would be silent in practice; short enough that the countdown the +// user is waiting on is not hidden for any meaningful part of its life. +constexpr qint64 kHintMs = 1500; + +} // namespace + +SendDialog::SendDialog(int delayMs, QWidget *parent) + : QDialog(parent) + , m_remainingMs(qMax(0, delayMs)) + , m_totalMs(qMax(0, delayMs)) +{ + setWindowTitle(tr("Sending")); + + // Modal to the composer, not to the application. Sending from one composer + // must not freeze a second composer or the main window. + setWindowModality(Qt::WindowModal); + + // No close button: during the countdown a bare dismissal is ambiguous, + // since it could equally mean "cancel" or "send now", so Undo is the only + // control that states which. This removes the AFFORDANCE only. Escape, + // close() and the window manager all still reach done(), and that override + // is what actually makes a dismissal safe; keyPressEvent() below merely + // spares the user an Escape that would silently undo. Reasoning about this + // flag alone is what left close() committing a send with no window up. + setWindowFlags((windowFlags() | Qt::CustomizeWindowHint) + & ~Qt::WindowCloseButtonHint); + + auto *layout = new QVBoxLayout(this); + + m_status = new QLabel(this); + m_status->setObjectName(QStringLiteral("sendStatus")); + + // Sized to the LONGEST string it can hold in the current language, not to + // its content. Italian "Rimozione della bozza..." is longer than "Removing + // draft...", so a label sized to whatever it happens to be showing resizes + // the popup between stages. Computed from tr() results at construction, so + // it is correct in whatever language is loaded AT THAT MOMENT. That is + // sufficient here and not in general: main() installs the QTranslator on + // its own stack before any window exists, so no dialog can outlive a + // language change. A runtime language switch would need this recomputed. + const QFontMetrics metrics(m_status->font()); + // The refusal hint is in this list too. It replaces the countdown text in + // the same label, so leaving it out would resize the popup at exactly the + // moment the user is being told the window will not close, which is the + // worst possible time for it to jump. + const QStringList candidates{ + tr("Sending in %1...").arg(99), + tr("Sending..."), + tr("Filing sent copy..."), + tr("Removing draft..."), + tr("Press Undo to stop sending."), + }; + int widest = 0; + for (const QString &candidate : candidates) + widest = qMax(widest, metrics.horizontalAdvance(candidate)); + m_status->setMinimumWidth(widest); + layout->addWidget(m_status); + + m_indicator = new BusyIndicator(this); + m_indicator->setObjectName(QStringLiteral("sendProgress")); + layout->addWidget(m_indicator); + + // Three rows in every state, so nothing reflows: Undo keeps its place and + // its size after it disables rather than vanishing. + auto *buttons = new QHBoxLayout; + buttons->addStretch(); + m_undo = new QPushButton(tr("Undo"), this); + m_undo->setObjectName(QStringLiteral("undoSend")); + buttons->addWidget(m_undo); + layout->addLayout(buttons); + + // Built BEFORE the Undo connection below, which stops it. The lambda would + // read a null m_timer otherwise, and only because nothing can click a + // button mid-constructor does the reverse order happen to survive. + m_timer = new QTimer(this); + m_timer->setObjectName(QStringLiteral("sendCountdown")); + m_timer->setInterval(kTickMs); + connect(m_timer, &QTimer::timeout, this, &SendDialog::tick); + + // Both the button and done() funnel into one place, so the two dismissal + // routes cannot drift into disagreeing about what a cancel does. + connect(m_undo, &QPushButton::clicked, this, [this] { undo(); }); + + if (m_totalMs == 0) { + // Queued rather than immediate, so a caller that connects to + // committed() AFTER constructing the dialog still hears it. Emitting + // from the constructor would send to nobody. + QTimer::singleShot(0, this, &SendDialog::commit); + } else { + setStage(Stage::CountingDown); + m_timer->start(); + } +} + +bool SendDialog::undo() +{ + // Undo is disabled at commit, but a disabled button is a UI property and + // not an invariant. This is the ONE place that can report "nothing was + // sent", so it refuses outright once the command is running rather than + // trusting the button's state. + // + // m_undone is the second half and is NOT redundant: it makes undone() + // fire exactly once however many times this is reached. + if (m_committed || m_undone) + return false; + m_undone = true; + + // The timer stops FIRST. A timer left running commits after the dialog has + // already reported that nothing was sent, which is the one outcome the + // whole delay exists to make impossible. + m_timer->stop(); + m_undo->setEnabled(false); + emit undone(); + + // Undo is the ONE route out before commit, so it is the one caller allowed + // through done()'s refusal. The flag is what distinguishes it from every + // other reject(); it is never cleared, because the dialog is finished. + m_undoing = true; + reject(); + return true; +} + +void SendDialog::refuseDismissal() +{ + // A window that ignores a close reads as a hang, so the refusal says where + // the exit is rather than doing nothing at all. One function because both + // done() and closeEvent() refuse, and two copies of this meant neutering + // either one left the other still setting the text, hiding the regression. + // + // Held for kHintMs, because the countdown's next tick is only kTickMs away + // and would otherwise overwrite the hint before it could be read, leaving + // the refusal effectively silent after all. setStage() honours the hold + // rather than this scheduling a restore, so the countdown keeps running + // underneath and there is no second timer to get out of step. + m_hintUntil = QDateTime::currentMSecsSinceEpoch() + kHintMs; + m_status->setText(tr("Press Undo to stop sending.")); + m_undo->setFocus(); +} + +void SendDialog::keyPressEvent(QKeyEvent *event) +{ + // QDialog maps Escape to reject(). Swallowed WITH ANY MODIFIER: Shift and + // Ctrl variants are the same keystroke as far as intent goes, and letting + // one through would be an undocumented back door to the same dismissal. + // done() would treat it safely as an Undo either way; this just spares the + // user a cancel they did not ask for by reflex. + if (event->key() == Qt::Key_Escape) { + event->accept(); + return; + } + QDialog::keyPressEvent(event); +} + +void SendDialog::done(int result) +{ + // Every dismissal route arrives here, which is the point: close(), the + // window manager, Escape and QDialog's own reject() all converge on + // done(), and guarding any one of them individually leaves the others + // open. Which routes are permitted, and when: + // + // BEFORE COMMIT, nothing closes the dialog except Undo. A close is + // REFUSED, not silently reinterpreted as a cancel: "close means undo" is + // confusing, because the user cannot tell whether dismissing the window + // stopped the send or merely hid it, and the two answers differ by whether + // their mail goes out. The popup carries exactly one control and it says + // what it does. Undo reaches QDialog::done() through m_undoing below. + // + // AFTER COMMIT, the send is in flight and there is nothing left to cancel, + // so any close is honoured. It is forced to Accepted so a caller reading + // result() cannot mistake a running send for a cancelled one. + // + // TASK 12 closes this dialog when the send finishes, and it does so after + // commit by definition, so the ordinary accept()/close() works and needs + // no special entry point. A stray reject() cannot reach the pre-commit + // state at all, which is the property this refusal buys. + if (m_committed) { + QDialog::done(QDialog::Accepted); + return; + } + + if (m_undoing) { + QDialog::done(QDialog::Rejected); + return; + } + + refuseDismissal(); +} + +void SendDialog::closeEvent(QCloseEvent *event) +{ + // Measured against a standalone Qt program, not assumed: close() on a + // dialog that was NEVER SHOWN reaches closeEvent() but returns BEFORE + // done(), so done()'s refusal alone would let that one route through. A + // shown dialog reaches both, and ignoring the event here stops it before + // done() is consulted. + if (!m_committed && !m_undoing) { + event->ignore(); + refuseDismissal(); + return; + } + QDialog::closeEvent(event); +} + +void SendDialog::tick() +{ + m_remainingMs -= kTickMs; + if (m_remainingMs <= 0) { + commit(); + return; + } + setStage(Stage::CountingDown); +} + +void SendDialog::commit() +{ + // Idempotent: a stray tick racing the singleShot must not emit twice. + if (m_committed) + return; + + m_timer->stop(); + m_committed = true; + + // Disabled, never hidden. A greyed Undo says why cancelling is no longer + // possible; an absent one only looks like it was never offered. + m_undo->setEnabled(false); + + setStage(Stage::Sending); + emit committed(); +} + +void SendDialog::setStage(Stage stage) +{ + // The enum is documented "in order", so the class enforces that rather + // than trusting its caller: Task 12 passes values from this public enum, + // and winding back would relabel a running send "Sending in 0..." and + // redraw a full countdown bar under it, offering a cancel that no longer + // exists. Only the backwards step is refused; the forward stages are the + // caller's to drive. + if (m_committed && stage == Stage::CountingDown) + return; + + // The refusal hint outranks the countdown text for as long as it is held. + // Only the countdown is suppressed: a stage change is a real event and + // must always be shown, and commit() clears the hold anyway. + if (stage == Stage::CountingDown + && QDateTime::currentMSecsSinceEpoch() < m_hintUntil) { + m_indicator->setProgress(m_remainingMs, m_totalMs); + return; + } + + switch (stage) { + case Stage::CountingDown: + // Rounded up, so a countdown with 1ms left still reads "1" rather than + // sitting on "0" for a tick. + m_status->setText(tr("Sending in %1...") + .arg((m_remainingMs + 999) / 1000)); + m_indicator->setProgress(m_remainingMs, m_totalMs); + return; + case Stage::Sending: + m_status->setText(tr("Sending...")); + break; + case Stage::FilingSentCopy: + m_status->setText(tr("Filing sent copy...")); + break; + case Stage::RemovingDraft: + m_status->setText(tr("Removing draft...")); + break; + } + + // Everything past the countdown: the duration stops being knowable, so the + // same widget switches from a fraction to an animation. + m_indicator->setBusy(true); +} diff --git a/src/senddialog.h b/src/senddialog.h new file mode 100644 index 0000000..a930c3d --- /dev/null +++ b/src/senddialog.h @@ -0,0 +1,145 @@ +/* + * 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 <QDialog> +#include <QtGlobal> + +class BusyIndicator; +class QLabel; +class QCloseEvent; +class QKeyEvent; +class QPushButton; +class QTimer; + +/// Owns a send from the cancellable countdown through to completion. +/// +/// The delay is where cancelling is SAFE and it is the only place it is. +/// Nothing has reached a server during the countdown, so Undo means genuinely +/// nothing happened. Killing send_command once it runs leaves an UNKNOWN send: +/// the message may have reached the server in full before the kill, which is +/// worse than either clean outcome. So there is no cancel after commit, and +/// isCommitted() is the line between the two. +/// +/// Three rows in every state, so nothing reflows and the window never jumps: +/// a status label, the bar, and Undo. +/// +/// The bar CHANGES MODE, it does not change place. Determinate while the +/// countdown drains, because a countdown has measurable progress; +/// indeterminate once the command starts, because a send does not. +/// +/// Modal to the composer, NOT to the application: sending from one composer +/// must not freeze a second composer or the main window. +/// +/// DISMISSAL IS A THIRD ROUTE TO THE SAME FAILURE, and removing the close +/// button only removes the affordance. Escape, the window manager, close() and +/// QDialog's own machinery all still reach done(); see done() and closeEvent() +/// below, which are the two places that cover them. An earlier revision +/// reasoned about Escape and the titlebar button alone and left close() +/// committing a send with no window on screen. +/// +/// Before commit, Undo is the ONLY way out and every other route is refused. +class SendDialog : public QDialog +{ + Q_OBJECT + +public: + /// \p delayMs of zero skips the countdown and sends at once. + explicit SendDialog(int delayMs, QWidget *parent = nullptr); + + /// The stages, in order. Each sets the label; every stage after the + /// countdown leaves the bar indeterminate. + enum class Stage { CountingDown, Sending, FilingSentCopy, RemovingDraft }; + Q_ENUM(Stage) + + void setStage(Stage stage); + + /// True once the countdown has elapsed and the command has started, after + /// which cancelling is no longer possible. + bool isCommitted() const { return m_committed; } + +signals: + /// The countdown elapsed or was skipped: the caller should start sending. + void committed(); + + /// Undo was pressed during the countdown. NOTHING has been sent. + void undone(); + +protected: + /// Swallows Escape, with any modifiers. QDialog maps it to reject(), and + /// during the countdown a bare dismissal is ambiguous in exactly the way + /// the constructor describes; Undo is the control that says which it means. + void keyPressEvent(QKeyEvent *event) override; + + /// The single choke point for every dismissal route, which is why the + /// close button's removal was not enough on its own: QDialog reaches + /// reject() from the window manager, from close(), and from its own + /// machinery, and all of them arrive here. + /// + /// During the countdown a close is REFUSED. "Close means undo" is + /// confusing: the user cannot tell whether dismissing the window stopped + /// the send or merely hid it, and the two answers differ by whether their + /// mail goes out. Undo is the only way out, which is what the popup's + /// single control already says. After commit any close is honoured, since + /// there is nothing left to cancel, and it is forced to Accepted so a + /// caller reading result() cannot mistake a running send for a cancelled + /// one. Task 12 closes the dialog after the send finishes, which is + /// post-commit by definition and so needs no special entry point. + void done(int result) override; + + /// CLAUDE.md's companion trap: close() on a widget that was never shown + /// returns early WITHOUT reaching done(), so done()'s refusal alone would + /// let exactly that one route through. Refuses on the same terms. + void closeEvent(QCloseEvent *event) override; + +private: + /// The one place that can report "nothing was sent". Returns false, and + /// does nothing at all, once the send has committed. Both the Undo button + /// and every dismissal route funnel through it. + bool undo(); + + /// Shows the hint that Undo is the only way out, and holds it long enough + /// to be read. One function because both refusal sites call it. + void refuseDismissal(); + + void tick(); + void commit(); + + QLabel *m_status = nullptr; + BusyIndicator *m_indicator = nullptr; + QPushButton *m_undo = nullptr; + QTimer *m_timer = nullptr; + + int m_remainingMs = 0; + int m_totalMs = 0; + bool m_committed = false; + + /// Set by the first undo(), so undone() is emitted exactly once however + /// many dismissal routes fire. A shown dialog's close() reaches BOTH + /// closeEvent() and done(). + bool m_undone = false; + + /// Deadline until which the refusal hint holds the status label against + /// the countdown's own text. Zero when no hint is showing. + qint64 m_hintUntil = 0; + + /// Set only by undo(), and what lets that one route through done()'s + /// pre-commit refusal. Every other reject() is turned away. + bool m_undoing = false; +}; diff --git a/src/types.h b/src/types.h index f4d387a..99c271d 100644 --- a/src/types.h +++ b/src/types.h @@ -239,6 +239,47 @@ struct DatabaseStats int tags = -1; ///< Distinct tag names in the database. }; +/// What opens a composer. Built by MainWindow, consumed by ComposeWindow. +/// +/// Built from the DATABASE, never from the model. The model's data comes from +/// the query, so a row whose state has not been re-queried carries stale +/// values, and a reply built from a stale row would carry the wrong +/// recipients. This is the same rule Restore already follows. +struct ComposeContext +{ + enum class Kind { New, Reply, ReplyAll, Forward }; + + QString accountKey; ///< Which account sends. Plain data here; the resolution rules live with whatever builds this context. + Kind kind = Kind::New; + QString originalPath; ///< The .eml being replied to or forwarded. Empty for New. + QString inReplyTo; ///< Message-ID of the original. + QStringList references; ///< The original's References plus its Message-ID. + QStringList to; ///< Pre-filled, the user's own addresses already stripped. + QStringList cc; + QString subject; ///< Re:/Fwd: prefixed, an existing prefix not doubled. + QString quotedBody; ///< The >-prefixed original. Empty when the action does not quote. + bool seedHtml = false; ///< Did the original carry a text/html part. + QStringList attachments; ///< Carried forward for Forward, empty otherwise. +}; + +/// What the composer produces, consumed by MessageBuilder. +/// +/// In-Reply-To and References are NOT optional. Without them a reply appears +/// as an orphan thread in the sender's own client. +struct OutgoingMessage +{ + QString accountKey; + QStringList to; + QStringList cc; + QStringList bcc; + QString subject; + QString markdownBody; ///< The source text, exactly as typed. + bool sendHtml = false; ///< The composer's per-message toggle. + QStringList attachments; ///< Local paths, read at build time. + QString inReplyTo; + QStringList references; +}; + Q_DECLARE_METATYPE(ThreadSummary) Q_DECLARE_METATYPE(MessageRef) Q_DECLARE_METATYPE(MessageNode) |
