diff options
Diffstat (limited to 'src/messagebuilder.cpp')
| -rw-r--r-- | src/messagebuilder.cpp | 407 |
1 files changed, 407 insertions, 0 deletions
diff --git a/src/messagebuilder.cpp b/src/messagebuilder.cpp new file mode 100644 index 0000000..42a0e31 --- /dev/null +++ b/src/messagebuilder.cpp @@ -0,0 +1,407 @@ +/* + * 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. +/// 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. +/// +/// 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); + +/// A message-id in the angle brackets the wire format requires, added if the +/// caller did not supply them. +/// +/// **The brackets are syntax, not decoration, and GMime enforces it by writing +/// an EMPTY HEADER for a bare addr-spec rather than by complaining.** Measured +/// 2026-08-21: `In-Reply-To: current@example.org` emits `In-Reply-To:` with no +/// value, so the reply arrives as an orphan thread in the recipient's client +/// while nothing looks wrong locally. +/// +/// Bracketing lives HERE, in the one function that composes these headers, +/// rather than in each caller. Every source of a message-id in this application +/// hands over a bare one: GMime strips the brackets when MimeParser reads +/// `Message-ID`, and `ComposeContextBuilder::referencesForReply` strips them +/// again from the References chain so the two agree. A convention spread across +/// callers is one a later caller gets wrong, and the failure is invisible +/// without inspecting a sent message. +QString bracketed(const QString &messageId) +{ + const QString id = messageId.trimmed(); + if (id.isEmpty()) + return {}; + if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>'))) + return id; + return QLatin1Char('<') + id + QLatin1Char('>'); +} + +bool setAddressHeader(GMimeMessage *message, const char *header, const QStringList &addresses, + QString *badEntry) +{ + if (addresses.isEmpty()) + return true; + + 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()); + 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); + } + + 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); + return true; +} + +} // 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 %1 has no address configured, so no message " + "can be sent from it.") + .arg(account.key); + 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. + // + // 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.isFile() || !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()); + + // 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?...?=). + const QByteArray subject = message.subject.toUtf8(); + g_mime_message_set_subject(mime, subject.constData(), "utf-8"); + + // Both headers are bracketed HERE rather than by the caller. See bracketed() + // for why, and for what a bare id costs. + const QString inReplyTo = bracketed(message.inReplyTo); + if (!inReplyTo.isEmpty()) { + const QByteArray value = inReplyTo.toUtf8(); + g_mime_object_set_header(GMIME_OBJECT(mime), "In-Reply-To", value.constData(), "utf-8"); + } + QStringList references; + for (const QString &id : message.references) { + const QString bracketedId = bracketed(id); + // An empty entry contributes nothing rather than a stray "<>": the + // References header is a run of ids, and one malformed entry is enough + // for a strict parser to discard the whole chain. + // + // Defensive rather than a path with a fixture behind it, like the + // length check in setAddressHeader above: referencesForReply() already + // drops empty ids, so a mutation on this line SURVIVES the suite. + // Measured 2026-08-21. Kept because it costs one comparison and the + // failure it covers is a silently broken thread. + if (!bracketedId.isEmpty()) + references.append(bracketedId); + } + if (!references.isEmpty()) { + const QByteArray value = 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(); + // 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); + 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.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); + result.messageId = messageId; + g_free(rendered); + } else { + result.error = QObject::tr("The message could not be assembled."); + } + + g_object_unref(mime); + return result; +} + +} // namespace MessageBuilder |
