diff options
Diffstat (limited to 'src/messagebuilder.cpp')
| -rw-r--r-- | src/messagebuilder.cpp | 286 |
1 files changed, 286 insertions, 0 deletions
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 |
