diff options
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/messagebuilder.cpp | 286 | ||||
| -rw-r--r-- | src/messagebuilder.h | 59 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_messagebuilder.cpp | 322 | ||||
| -rw-r--r-- | translations/qtmaildir_it_IT.ts | 16 |
6 files changed, 685 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 12168a5..b7a5be2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(qtmaildir_lib STATIC config.cpp mimeparser.cpp markdownrenderer.cpp + messagebuilder.cpp requestinterceptor.cpp htmlbuilder.cpp cidschemehandler.cpp diff --git a/src/messagebuilder.cpp b/src/messagebuilder.cpp new file mode 100644 index 0000000..04982c9 --- /dev/null +++ b/src/messagebuilder.cpp @@ -0,0 +1,286 @@ +/* + * 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. +/// +/// 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) +{ + if (addresses.isEmpty()) + return; + + 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()); + if (!parsed) + continue; + 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); +} + +} // 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 has no address configured, so no message " + "can be sent from it."); + 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. + for (const QString &path : message.attachments) { + const QFileInfo info(path); + if (!info.exists() || !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()); + + 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); + + // 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"); + + if (!message.inReplyTo.trimmed().isEmpty()) { + const QByteArray value = message.inReplyTo.trimmed().toUtf8(); + g_mime_object_set_header(GMIME_OBJECT(mime), "In-Reply-To", value.constData(), "utf-8"); + } + if (!message.references.isEmpty()) { + const QByteArray value = message.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(); + 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); + 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.bytes.clear(); + result.messageId.clear(); + 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); + g_free(rendered); + } else { + result.error = QObject::tr("The message could not be assembled."); + result.messageId.clear(); + } + + 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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 15f9955..8c6231d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -69,6 +69,7 @@ add_qtmaildir_test(busyindicator) add_qtmaildir_test(tagstrip) add_qtmaildir_test(messagedetailsdialog) add_qtmaildir_test(markdownrenderer) +add_qtmaildir_test(messagebuilder) add_qtmaildir_test(maildirname) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated diff --git a/tests/test_messagebuilder.cpp b/tests/test_messagebuilder.cpp new file mode 100644 index 0000000..289006c --- /dev/null +++ b/tests/test_messagebuilder.cpp @@ -0,0 +1,322 @@ +/* + * 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 <QDir> +#include <QFile> +#include <QObject> +#include <QRegularExpression> +#include <QTemporaryDir> +#include <QTest> + +#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 attachmentsProduceMultipartMixed(); + void aMissingAttachmentFailsTheBuild(); + 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("<strong>bold</strong>")), 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("<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: <orig@example.org>")), qPrintable(text)); + QVERIFY2(text.contains(QStringLiteral("References:")), qPrintable(text)); + QVERIFY2(text.contains(QStringLiteral("<older@example.org>")), 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)); +} + +/// 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" diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index 4dbc62d..e4772bf 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -1234,6 +1234,22 @@ <source>Rule '%1': adds and removes nothing; dropped</source> <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> + </message> + <message> + <source>The attachment %1 is missing or unreadable.</source> + <translation>L'allegato %1 è mancante o non leggibile.</translation> + </message> + <message> + <source>The attachment %1 could not be read.</source> + <translation>Non è stato possibile leggere l'allegato %1.</translation> + </message> + <message> + <source>The message could not be assembled.</source> + <translation>Non è stato possibile comporre il messaggio.</translation> + </message> </context> <context> <name>QueryCompleter</name> |
