/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * 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 #include #include #include #include #include #include #include #include #include "config.h" #include "messagebuilder.h" #include "types.h" /// MessageBuilder's tests assert on the GENERATED BYTES, never by round-tripping /// through MimeParser. A builder and a parser that agree can be wrong together: /// both are ours, and a shared misunderstanding of a charset or a part order /// would show as a green suite and as mojibake on the recipient's screen. class TestMessageBuilder : public QObject { Q_OBJECT private slots: void initTestCase(); void plainOnlyWhenSendHtmlIsOff(); void multipartAlternativeWhenSendHtmlIsOn(); void thePlainPartCarriesTheMarkdownSourceUnmodified(); void theHtmlPartIsRenderedFromTheSameSource(); void anAccentedBodyIsUtf8QuotedPrintable(); void anAccentedSubjectIsRfc2047Utf8(); void inReplyToAndReferencesAreCarried(); void bareMessageIdsAreBracketedRatherThanEmittedEmpty(); void attachmentsProduceMultipartMixed(); void aMissingAttachmentFailsTheBuild(); void aDirectoryAttachmentFailsRatherThanHangingTheProcess(); void anUnparseableRecipientFailsRatherThanVanishing(); void everyMessageCarriesADateAndMessageId(); void recipientsAppearInTheirOwnHeaders(); void anAccountWithNoAddressFailsRatherThanBuildingHeaderlessMail(); private: Account m_account; /// A message with the fixture account and one recipient, so each test can /// change only the field it is about. OutgoingMessage baseMessage() const { OutgoingMessage m; m.accountKey = m_account.key; m.to = QStringList{QStringLiteral("someone@example.org")}; m.subject = QStringLiteral("A subject"); m.markdownBody = QStringLiteral("Hello there."); return m; } }; void TestMessageBuilder::initTestCase() { m_account.key = QStringLiteral("work"); m_account.name = QStringLiteral("Danilo M."); m_account.address = QStringLiteral("user@example.org"); m_account.maildir = QStringLiteral("work"); m_account.sendCommand = QStringLiteral("/bin/true"); } /// With the HTML toggle off the message must be a single text/plain part. /// A multipart/alternative carrying one alternative is not merely wasteful: it /// makes every message an attachment-bearing shape to some clients, and the /// toggle exists precisely so a user can send mail nothing has to negotiate. void TestMessageBuilder::plainOnlyWhenSendHtmlIsOff() { OutgoingMessage m = baseMessage(); m.sendHtml = false; const MessageBuilder::Result r = MessageBuilder::build(m, m_account); QVERIFY2(r.ok(), qPrintable(r.error)); const QString text = QString::fromUtf8(r.bytes); QVERIFY(text.contains(QStringLiteral("Content-Type: text/plain"))); QVERIFY(!text.contains(QStringLiteral("multipart/alternative"))); QVERIFY(!text.contains(QStringLiteral("text/html"))); } /// With the toggle on both parts must be present, and text/plain must come /// FIRST. Order is load-bearing in multipart/alternative: a client renders the /// LAST part it understands, so least-rich first. Reversed, every HTML-capable /// client would show the markdown source and the rendered part would never be /// seen by anyone. void TestMessageBuilder::multipartAlternativeWhenSendHtmlIsOn() { OutgoingMessage m = baseMessage(); m.sendHtml = true; const MessageBuilder::Result r = MessageBuilder::build(m, m_account); QVERIFY2(r.ok(), qPrintable(r.error)); const QString text = QString::fromUtf8(r.bytes); QVERIFY(text.contains(QStringLiteral("multipart/alternative"))); const int plain = text.indexOf(QStringLiteral("text/plain")); const int html = text.indexOf(QStringLiteral("text/html")); QVERIFY(plain >= 0); QVERIFY(html >= 0); QVERIFY2(plain < html, "text/plain must precede text/html in multipart/alternative"); } /// The markdown SOURCE is the plain part, not a stripped-of-syntax rendering of /// it. `**bold**` reads as emphasis to a human, and a plain-text renderer would /// mean inventing a second renderer whose output could disagree with the HTML /// one. The draft the user autosaves is this same text, which is the other /// reason it must not be rewritten on the way out. void TestMessageBuilder::thePlainPartCarriesTheMarkdownSourceUnmodified() { OutgoingMessage m = baseMessage(); m.sendHtml = true; m.markdownBody = QStringLiteral("**bold** and - [ ] a task"); 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("**bold** and - [ ] a task")), qPrintable(text)); } /// The HTML part comes from the same source through MarkdownRenderer, so the /// two parts can never describe different messages. void TestMessageBuilder::theHtmlPartIsRenderedFromTheSameSource() { OutgoingMessage m = baseMessage(); m.sendHtml = true; m.markdownBody = QStringLiteral("**bold**"); 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("bold")), qPrintable(text)); } /// Measured 2026-08-20: g_mime_text_part_set_text() encodes with whatever /// charset is set at the moment it is CALLED, so setting the charset afterwards /// RELABELS the part without re-encoding it. That produces a part headed /// charset=utf-8 whose bytes are latin-1 (`Perch=E9`), which looks correct in /// every header and arrives as mojibake. Asserting on the label alone would /// pass against exactly that bug, so this asserts on the BYTES too: =C3=A9 must /// be there and =E9 must not. void TestMessageBuilder::anAccentedBodyIsUtf8QuotedPrintable() { OutgoingMessage m = baseMessage(); m.markdownBody = QStringLiteral("perché è così"); 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("charset=utf-8"), Qt::CaseInsensitive), qPrintable(text)); QVERIFY2(text.contains(QStringLiteral("=C3=A9")), qPrintable(text)); QVERIFY2(!text.contains(QStringLiteral("=E9\n")) && !text.contains(QStringLiteral("=E9 ")), "latin-1 bytes under a utf-8 label"); } /// Measured 2026-08-20: GMime encodes a header as iso-8859-1 unless told /// otherwise, so g_mime_message_set_subject(msg, text, NULL) produced /// =?iso-8859-1?B?...?=. The explicit "utf-8" argument is what makes an Italian /// subject survive. void TestMessageBuilder::anAccentedSubjectIsRfc2047Utf8() { OutgoingMessage m = baseMessage(); m.subject = QStringLiteral("Perché no"); 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("=?UTF-8?"), Qt::CaseInsensitive), qPrintable(text)); QVERIFY2(!text.contains(QStringLiteral("=?iso-8859-1?"), Qt::CaseInsensitive), qPrintable(text)); } /// Not optional decoration. Without In-Reply-To and References a reply appears /// as an orphan thread in the sender's own client, since the sent copy is /// indexed by notmuch like any other message and notmuch threads on these /// headers. void TestMessageBuilder::inReplyToAndReferencesAreCarried() { OutgoingMessage m = baseMessage(); m.inReplyTo = QStringLiteral(""); m.references = QStringList{QStringLiteral(""), QStringLiteral("")}; 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)); 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 /// unreadable. Position in the byte stream is what distinguishes the two, so the /// test asserts mixed appears BEFORE alternative. void TestMessageBuilder::attachmentsProduceMultipartMixed() { QTemporaryDir dir; QVERIFY(dir.isValid()); const QString path = dir.filePath(QStringLiteral("notes.txt")); QFile f(path); QVERIFY(f.open(QIODevice::WriteOnly)); f.write("some attached bytes\n"); f.close(); OutgoingMessage m = baseMessage(); m.sendHtml = true; m.attachments = QStringList{path}; const MessageBuilder::Result r = MessageBuilder::build(m, m_account); QVERIFY2(r.ok(), qPrintable(r.error)); const QString text = QString::fromUtf8(r.bytes); const int mixed = text.indexOf(QStringLiteral("multipart/mixed")); const int alternative = text.indexOf(QStringLiteral("multipart/alternative")); QVERIFY2(mixed >= 0, qPrintable(text)); QVERIFY2(alternative >= 0, qPrintable(text)); QVERIFY2(mixed < alternative, "multipart/mixed must wrap the body, not sit beside it"); QVERIFY2(text.contains(QStringLiteral("notes.txt")), qPrintable(text)); QVERIFY2(text.contains(QStringLiteral("Content-Disposition: attachment")), qPrintable(text)); } /// A file can vanish between being attached and being sent, so existence is /// checked at BUILD time. The build must produce NOTHING sendable: an empty /// `bytes` is what stops a caller that only checks for content from shipping a /// message missing the thing it was written to carry. void TestMessageBuilder::aMissingAttachmentFailsTheBuild() { OutgoingMessage m = baseMessage(); m.attachments = QStringList{QStringLiteral("/nonexistent/path/to/report.pdf")}; const MessageBuilder::Result r = MessageBuilder::build(m, m_account); QVERIFY(!r.ok()); QVERIFY(r.bytes.isEmpty()); QVERIFY2(r.error.contains(QStringLiteral("report.pdf")), qPrintable(r.error)); } /// A directory is not a file that can be attached, and accepting one does not /// produce a bad message, it produces NO message ever: QFileInfo reports a /// directory as existing and readable, opening one read-only is legal, and /// GMime's base64 encoder then loops on a read() returning EISDIR without /// advancing. Measured 2026-08-20 with strace at 2,169,821 failed reads in /// twenty seconds and still going. build() runs synchronously from autosave on /// the GUI thread, so this froze the whole application with the draft /// unrecoverable. /// /// The TIMEOUT is deliberate and is the point of the test's shape. A regression /// here hangs the binary rather than failing it, and CLAUDE.md already records /// a hung test binary as a misleading failure mode that costs a session. The /// build runs on a worker thread so this test can outlive it and report a /// FAILURE instead of blocking ctest until its own timeout. /// /// Two details are what make that actually work, and the first draft of this /// test had neither. It must NOT join the worker: a thread stuck in the defect /// never returns, so a wait() after the timeout hangs exactly as the bug does /// and the recorded failure is never printed. Verified by reverting the fix: /// with the join the binary had to be killed at 150s with no verdict, without /// it the run reports a FAIL and finishes. The worker is therefore deliberately /// leaked on the failing path, which is correct for a test binary about to exit /// and is the only way this reports rather than hangs. The result is read /// through a shared_ptr for the same reason: a leaked thread must not write /// into a stack frame that has returned. void TestMessageBuilder::aDirectoryAttachmentFailsRatherThanHangingTheProcess() { QTemporaryDir dir; QVERIFY(dir.isValid()); const QString subdir = dir.filePath(QStringLiteral("a-folder")); QVERIFY(QDir().mkpath(subdir)); // The guard this protects: a directory looks like a perfectly good // attachment to the checks that were there before. const QFileInfo info(subdir); QVERIFY(info.exists()); QVERIFY(info.isReadable()); QVERIFY(!info.isFile()); OutgoingMessage m = baseMessage(); m.attachments = QStringList{subdir}; // Shared with the worker rather than captured by reference, so a thread // still spinning after this function returns cannot write into a dead // frame. struct Shared { std::atomic_bool finished{false}; MessageBuilder::Result result; }; auto shared = std::make_shared(); const OutgoingMessage msg = m; const Account account = m_account; QThread *worker = QThread::create([shared, msg, account] { shared->result = MessageBuilder::build(msg, account); shared->finished = true; }); worker->start(); // Five seconds against a defect measured at twenty seconds and unbounded. // No join: see the note above, waiting on the stuck thread reproduces the // hang instead of reporting it. QTRY_VERIFY_WITH_TIMEOUT(shared->finished.load(), 5000); if (!shared->finished.load()) QFAIL("build() did not return for a directory attachment: it is looping on read()"); worker->wait(); delete worker; QVERIFY(!shared->result.ok()); QVERIFY(shared->result.bytes.isEmpty()); QVERIFY2(shared->result.error.contains(QStringLiteral("a-folder")), qPrintable(shared->result.error)); } /// A recipient the builder cannot parse must STOP the send, never be dropped. /// Measured 2026-08-20: internet_address_list_parse returns a ZERO-LENGTH list /// rather than NULL for garbage, so a guard on the assembled list's length /// built a message with no To: header at all and reported success. With /// `msmtp -t` the recipients come FROM the headers, so that message reaches the /// send command with nobody to deliver to, and the sent copy is filed in Sent /// looking sent and having reached no one. /// /// Asserts on the error naming the offending entry, because with several /// recipients the user cannot otherwise tell which one to fix. void TestMessageBuilder::anUnparseableRecipientFailsRatherThanVanishing() { OutgoingMessage m = baseMessage(); m.to = QStringList{QStringLiteral("not an address at all ((("), QStringLiteral("good@example.org")}; const MessageBuilder::Result r = MessageBuilder::build(m, m_account); QVERIFY2(!r.ok(), "an unparseable recipient must fail the build"); QVERIFY(r.bytes.isEmpty()); QVERIFY2(r.error.contains(QStringLiteral("not an address at all")), qPrintable(r.error)); // The other half of the same defect: with several recipients, the old code // delivered the good ones and dropped the bad one without a word, so the // user had no way to learn which recipient never received the message. A // valid entry beside the bad one must not rescue the build. QVERIFY2(!r.bytes.contains("good@example.org"), "a valid recipient must not smuggle the message past a bad one"); } /// Measured 2026-08-20: GMime generates neither header unless asked. A message /// without a Message-ID cannot be threaded by anything that receives it, /// including this application's own notmuch index once the sent copy lands. void TestMessageBuilder::everyMessageCarriesADateAndMessageId() { const MessageBuilder::Result r = MessageBuilder::build(baseMessage(), m_account); QVERIFY2(r.ok(), qPrintable(r.error)); const QString text = QString::fromUtf8(r.bytes); QVERIFY2(text.contains(QStringLiteral("Date: ")), qPrintable(text)); QVERIFY2(text.contains(QStringLiteral("Message-Id: "), Qt::CaseInsensitive), qPrintable(text)); QVERIFY(!r.messageId.isEmpty()); } /// Bcc must be PRESENT in the bytes. The documented send command is `msmtp -t`, /// which reads its recipients FROM the headers and strips Bcc itself before /// transmission. Removing it here would mean blind recipients never receive the /// message at all, silently. /// /// If a later change passes recipients as command arguments instead of relying /// on -t, this test must change with it: under that scheme leaving Bcc in the /// bytes discloses the blind recipients to everyone. void TestMessageBuilder::recipientsAppearInTheirOwnHeaders() { OutgoingMessage m = baseMessage(); m.to = QStringList{QStringLiteral("to@example.org")}; m.cc = QStringList{QStringLiteral("cc@example.org")}; m.bcc = QStringList{QStringLiteral("bcc@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("From: ")), qPrintable(text)); QVERIFY2(text.contains(QStringLiteral("user@example.org")), qPrintable(text)); const QRegularExpression to(QStringLiteral("^To:.*to@example\\.org"), QRegularExpression::MultilineOption); const QRegularExpression cc(QStringLiteral("^Cc:.*cc@example\\.org"), QRegularExpression::MultilineOption); const QRegularExpression bcc(QStringLiteral("^Bcc:.*bcc@example\\.org"), QRegularExpression::MultilineOption); QVERIFY2(to.match(text).hasMatch(), qPrintable(text)); QVERIFY2(cc.match(text).hasMatch(), qPrintable(text)); QVERIFY2(bcc.match(text).hasMatch(), qPrintable(text)); } /// Config::account() returns a DEFAULT-CONSTRUCTED Account for an unknown key /// rather than failing, so without this guard a bad key would build a message /// with an empty From: silently malformed mail rather than a refusal, handed to /// the send command as though it were fine. void TestMessageBuilder::anAccountWithNoAddressFailsRatherThanBuildingHeaderlessMail() { const Account empty; const MessageBuilder::Result r = MessageBuilder::build(baseMessage(), empty); QVERIFY(!r.ok()); QVERIFY(r.bytes.isEmpty()); } QTEST_MAIN(TestMessageBuilder) #include "test_messagebuilder.moc"