diff options
| -rw-r--r-- | src/messagebuilder.cpp | 112 | ||||
| -rw-r--r-- | tests/test_messagebuilder.cpp | 111 | ||||
| -rw-r--r-- | translations/qtmaildir_it_IT.ts | 8 |
3 files changed, 210 insertions, 21 deletions
diff --git a/src/messagebuilder.cpp b/src/messagebuilder.cpp index 04982c9..f27c3c2 100644 --- a/src/messagebuilder.cpp +++ b/src/messagebuilder.cpp @@ -76,16 +76,44 @@ GMimePart *makeTextPart(const char *subtype, const QString &text) } /// 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. -void setAddressHeader(GMimeMessage *message, const char *header, const QStringList &addresses) +/// +/// 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) { if (addresses.isEmpty()) - return; + return true; InternetAddressList *list = internet_address_list_new(); for (const QString &entry : addresses) { @@ -94,8 +122,14 @@ void setAddressHeader(GMimeMessage *message, const char *header, const QStringLi continue; const QByteArray utf8 = trimmed.toUtf8(); InternetAddressList *parsed = internet_address_list_parse(nullptr, utf8.constData()); - if (!parsed) - continue; + 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); } @@ -109,6 +143,7 @@ void setAddressHeader(GMimeMessage *message, const char *header, const QStringLi } } g_object_unref(list); + return true; } } // namespace @@ -125,8 +160,9 @@ Result build(const OutgoingMessage &message, const Account &account) // 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 has no address configured, so no message " - "can be sent from it."); + result.error = QObject::tr("The account %1 has no address configured, so no message " + "can be sent from it.") + .arg(account.key); return result; } @@ -134,9 +170,19 @@ Result build(const OutgoingMessage &message, const Account &account) // 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.isReadable()) { + 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; @@ -153,14 +199,39 @@ Result build(const OutgoingMessage &message, const Account &account) account.name.isEmpty() ? nullptr : fromName.constData(), fromAddress.constData()); - setAddressHeader(mime, "To", message.to); - setAddressHeader(mime, "Cc", message.cc); - // Bcc is written into the bytes deliberately. The documented send command - // is `msmtp -t`, which reads its recipients FROM the headers and strips Bcc - // itself before transmission; omitting it here would mean blind recipients - // never receive the message at all, silently. If sending ever passes - // recipients as arguments instead, this line must go with it. - setAddressHeader(mime, "Bcc", message.bcc); + // 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?...?=). @@ -185,10 +256,15 @@ Result build(const OutgoingMessage &message, const Account &account) 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); - result.messageId = QString::fromUtf8(generatedId); + messageId = QString::fromUtf8(generatedId); g_free(generatedId); } @@ -243,8 +319,6 @@ Result build(const OutgoingMessage &message, const Account &account) g_object_unref(part); g_object_unref(mixed); g_object_unref(mime); - result.bytes.clear(); - result.messageId.clear(); result.error = QObject::tr("The attachment %1 could not be read.") .arg(info.fileName()); return result; @@ -273,10 +347,10 @@ Result build(const OutgoingMessage &message, const Account &account) 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."); - result.messageId.clear(); } g_object_unref(mime); diff --git a/tests/test_messagebuilder.cpp b/tests/test_messagebuilder.cpp index 289006c..1f94784 100644 --- a/tests/test_messagebuilder.cpp +++ b/tests/test_messagebuilder.cpp @@ -18,11 +18,15 @@ #include <QDir> #include <QFile> +#include <QThread> #include <QObject> #include <QRegularExpression> #include <QTemporaryDir> #include <QTest> +#include <atomic> +#include <memory> + #include "config.h" #include "messagebuilder.h" #include "types.h" @@ -47,6 +51,8 @@ private slots: void inReplyToAndReferencesAreCarried(); void attachmentsProduceMultipartMixed(); void aMissingAttachmentFailsTheBuild(); + void aDirectoryAttachmentFailsRatherThanHangingTheProcess(); + void anUnparseableRecipientFailsRatherThanVanishing(); void everyMessageCarriesADateAndMessageId(); void recipientsAppearInTheirOwnHeaders(); void anAccountWithNoAddressFailsRatherThanBuildingHeaderlessMail(); @@ -259,6 +265,111 @@ void TestMessageBuilder::aMissingAttachmentFailsTheBuild() 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<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. diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index e4772bf..76652b8 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -1235,14 +1235,18 @@ <translation>Regola '%1': non aggiunge né rimuove nulla; scartata</translation> </message> <message> - <source>The account has no address configured, so no message can be sent from it.</source> - <translation>L'account non ha un indirizzo configurato, quindi non è possibile inviare messaggi da esso.</translation> + <source>The account %1 has no address configured, so no message can be sent from it.</source> + <translation>L'account %1 non ha un indirizzo configurato, quindi non è possibile inviare messaggi da esso.</translation> </message> <message> <source>The attachment %1 is missing or unreadable.</source> <translation>L'allegato %1 è mancante o non leggibile.</translation> </message> <message> + <source>%1 is not an address this can send to.</source> + <translation>%1 non è un indirizzo a cui sia possibile inviare.</translation> + </message> + <message> <source>The attachment %1 could not be read.</source> <translation>Non è stato possibile leggere l'allegato %1.</translation> </message> |
