summaryrefslogtreecommitdiffstats
path: root/src/composecontext.h
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-21 15:37:54 +0200
committerDanilo M. <danix@danix.xyz>2026-08-21 15:37:54 +0200
commit2b32350204dfb49089c465856464a043018ca3c6 (patch)
tree52f25e280bd9a66d5c7cfd26a855cdce4c4c7d31 /src/composecontext.h
parent6488810c779970094b86079c8688d83d8529fab0 (diff)
downloadqtmaildir-2b32350204dfb49089c465856464a043018ca3c6.tar.gz
qtmaildir-2b32350204dfb49089c465856464a043018ca3c6.zip
feat(compose): derive a reply's recipients and headers, item 123
ComposeContext, task 7 of the compose-and-send plan. Address parsing, recipient derivation, the References chain, subject prefixing and account resolution, as free functions over values so they test without a painter. Recipient derivation was designed from the spec rather than transcribed: the plan's draft omitted it and its tests could not compile, calling QVERIFY(config.load(path)) against a void return. Six defects found in review, each pinned by a test checked against the mutation that breaks it: - Message-ids reached GMime bare, and GMime writes an EMPTY header for a bare addr-spec rather than complaining. In-Reply-To and References both shipped blank, so every reply would have arrived as an orphan thread with nothing wrong to see locally. MessageBuilder now brackets on write, in the one place that composes those headers rather than in each caller. - internet_address_to_string was called with FALSE for the encode flag, so a display name carrying a raw newline rendered with the newline intact. That is a header-injection primitive. - A reply to the user's own message addressed the user. It now goes to that message's original recipients, mirroring their To/Cc split, which is what the Sent view and a follow-up on unanswered mail need. - A From parsing to no mailbox left To empty, reachable from real mail ("From: Mailer Daemon"). MessageBuilder treats an empty recipient list as success, so the message would have been handed to the send command with nobody to deliver to and filed in Sent looking sent. - The References header was split on whitespace alone, so a client's non-conformant "<a@x>,<b@y>" became one token and the bracket strip produced the fabricated id "a@x>,<b@y". - Reply and forward prefixes were recognised in English only, doubling every AW:, SV:, WG: and Re[2]: a mixed-locale mailbox receives. Single-letter spellings are deliberately excluded: with R: recognised, "R: report on Q3" reads as a prefix and a genuine first reply threads nowhere. The mailbox-only guard in parseAddressHeader survived its first mutation check, because removing it still yields no recipients: the invalid GObject cast makes GMime's own assertion return NULL. That is undefined behaviour papered over by an assertion G_DISABLE_CHECKS compiles out, so the test now asserts on the emitted critical rather than on the count. Registering the log handler on a NULL domain catches nothing; the criticals carry "GLib-GObject" and "gmime". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoaLBowZ6w1JNx6SEhDP1L
Diffstat (limited to 'src/composecontext.h')
-rw-r--r--src/composecontext.h177
1 files changed, 177 insertions, 0 deletions
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