From 2b32350204dfb49089c465856464a043018ca3c6 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 21 Aug 2026 15:37:54 +0200 Subject: 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 "," became one token and the bracket strip produced the fabricated id "a@x>, Claude-Session: https://claude.ai/code/session_01LoaLBowZ6w1JNx6SEhDP1L --- tests/test_messagebuilder.cpp | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) (limited to 'tests/test_messagebuilder.cpp') diff --git a/tests/test_messagebuilder.cpp b/tests/test_messagebuilder.cpp index 1f94784..73d388c 100644 --- a/tests/test_messagebuilder.cpp +++ b/tests/test_messagebuilder.cpp @@ -49,6 +49,7 @@ private slots: void anAccentedBodyIsUtf8QuotedPrintable(); void anAccentedSubjectIsRfc2047Utf8(); void inReplyToAndReferencesAreCarried(); + void bareMessageIdsAreBracketedRatherThanEmittedEmpty(); void attachmentsProduceMultipartMixed(); void aMissingAttachmentFailsTheBuild(); void aDirectoryAttachmentFailsRatherThanHangingTheProcess(); @@ -218,6 +219,38 @@ void TestMessageBuilder::inReplyToAndReferencesAreCarried() QVERIFY2(text.contains(QStringLiteral("")), qPrintable(text)); } +/// **The brackets are syntax, and a bare id ships an EMPTY header rather than a +/// malformed one.** This is what every real caller supplies: GMime strips the +/// brackets when MimeParser reads Message-ID, and +/// ComposeContextBuilder::referencesForReply strips them from the References +/// chain so the two agree, so both values arrive here bare. +/// +/// Measured 2026-08-21: handed `orig@example.org`, GMime wrote `In-Reply-To:` +/// with no value at all and did not complain. Every reply would have arrived as +/// an orphan thread in the recipient's client, with nothing wrong to see +/// locally. Asserted on the FULL header line, since a test for the id alone +/// passes against an empty header that merely contains the name. +void TestMessageBuilder::bareMessageIdsAreBracketedRatherThanEmittedEmpty() +{ + OutgoingMessage m = baseMessage(); + m.inReplyTo = QStringLiteral("orig@example.org"); + m.references = QStringList{QStringLiteral("older@example.org"), + QStringLiteral("orig@example.org")}; + + const MessageBuilder::Result r = MessageBuilder::build(m, m_account); + QVERIFY2(r.ok(), qPrintable(r.error)); + + const QString text = QString::fromUtf8(r.bytes); + QVERIFY2(text.contains(QStringLiteral("In-Reply-To: ")), qPrintable(text)); + QVERIFY2(text.contains( + QStringLiteral("References: ")), + qPrintable(text)); + // The failure this exists for: the header present and empty. + QVERIFY2(!text.contains(QStringLiteral("In-Reply-To:\r\n")) + && !text.contains(QStringLiteral("In-Reply-To:\n")), + qPrintable(text)); +} + /// The attachment wrapper must NEST the body, not sit beside it: multipart/mixed /// outermost, with the multipart/alternative as its first part. Beside it, a /// client would show the alternatives as attachments and the body would be -- cgit v1.2.3