aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/messagebuilder.cpp286
-rw-r--r--src/messagebuilder.h59
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_messagebuilder.cpp322
-rw-r--r--translations/qtmaildir_it_IT.ts16
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 &apos;%1&apos;: adds and removes nothing; dropped</source>
<translation>Regola &apos;%1&apos;: 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&apos;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&apos;allegato %1 è mancante o non leggibile.</translation>
+ </message>
+ <message>
+ <source>The attachment %1 could not be read.</source>
+ <translation>Non è stato possibile leggere l&apos;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>