diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-02 17:27:07 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-02 17:27:07 +0200 |
| commit | 35d3e4ff127b0213fa4d34f630c4a019e533a4df (patch) | |
| tree | 7cf50ed64ad5886ff909ab8ab2a26d99b2d90947 /src | |
| parent | b61ce428d81871fbbc6261f274f20785db9d1c38 (diff) | |
| download | qtmaildir-35d3e4ff127b0213fa4d34f630c4a019e533a4df.tar.gz qtmaildir-35d3e4ff127b0213fa4d34f630c4a019e533a4df.zip | |
feat: add MimeParser with GMime and safe attachment naming
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/mimeparser.cpp | 218 | ||||
| -rw-r--r-- | src/mimeparser.h | 64 |
3 files changed, 283 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d1ed29f..8dfd212 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,7 @@ add_library(qtmaildir_lib STATIC keymap.cpp config.cpp + mimeparser.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp new file mode 100644 index 0000000..7393a96 --- /dev/null +++ b/src/mimeparser.cpp @@ -0,0 +1,218 @@ +// 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 "mimeparser.h" + +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QRegularExpression> +#include <QUuid> + +namespace { + +/// GMime must be initialised exactly once per process. +void ensureGMimeInit() +{ + static bool initialised = false; + if (!initialised) { + g_mime_init(); + initialised = true; + } +} + +QString fromGChar(char *owned) +{ + if (!owned) + return {}; + const QString result = QString::fromUtf8(owned); + g_free(owned); + return result; +} + +QString headerText(GMimeMessage *message, const char *name) +{ + GMimeHeaderList *headers = g_mime_object_get_header_list( + GMIME_OBJECT(message)); + if (!headers) + return {}; + GMimeHeader *header = g_mime_header_list_get_header(headers, name); + if (!header) + return {}; + // get_value() returns the RFC 2047-decoded value. + return QString::fromUtf8(g_mime_header_get_value(header)); +} + +QByteArray decodePart(GMimePart *part) +{ + GMimeDataWrapper *content = g_mime_part_get_content(part); + if (!content) + return {}; + + GMimeStream *memStream = g_mime_stream_mem_new(); + g_mime_data_wrapper_write_to_stream(content, memStream); + g_mime_stream_flush(memStream); + + GByteArray *bytes = g_mime_stream_mem_get_byte_array( + GMIME_STREAM_MEM(memStream)); + QByteArray result(reinterpret_cast<const char *>(bytes->data), bytes->len); + + g_object_unref(memStream); + return result; +} + +/// Walks the MIME tree, filling the parsed message. +void collectParts(GMimeObject *object, ParsedMessage &out) +{ + if (GMIME_IS_MULTIPART(object)) { + GMimeMultipart *multipart = GMIME_MULTIPART(object); + const int count = g_mime_multipart_get_count(multipart); + for (int i = 0; i < count; ++i) + collectParts(g_mime_multipart_get_part(multipart, i), out); + return; + } + + if (GMIME_IS_MESSAGE_PART(object)) { + GMimeMessage *sub = g_mime_message_part_get_message( + GMIME_MESSAGE_PART(object)); + if (sub) + collectParts(g_mime_message_get_mime_part(sub), out); + return; + } + + if (!GMIME_IS_PART(object)) + return; + + GMimePart *part = GMIME_PART(object); + GMimeContentType *contentType = g_mime_object_get_content_type(object); + // g_mime_content_type_get_mime_type() returns a newly-allocated string + // that must be freed; fromGChar() takes ownership of it. + const QString mimeType = contentType + ? fromGChar(g_mime_content_type_get_mime_type(contentType)) + : QStringLiteral("application/octet-stream"); + + const char *disposition = g_mime_object_get_disposition(object); + const bool isAttachment = + disposition && g_ascii_strcasecmp(disposition, "attachment") == 0; + + const char *contentId = g_mime_part_get_content_id(part); + + if (isAttachment) { + Attachment attachment; + attachment.mimeType = mimeType; + attachment.data = decodePart(part); + const char *filename = g_mime_part_get_filename(part); + attachment.filename = filename + ? QString::fromUtf8(filename) + : QStringLiteral("attachment"); + out.attachments.append(attachment); + return; + } + + if (contentId) { + // Strip the angle brackets so the key matches a cid: URL body. + QString id = QString::fromUtf8(contentId); + if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>'))) + id = id.mid(1, id.size() - 2); + out.inlineParts.insert(id, InlinePart{ mimeType, decodePart(part) }); + return; + } + + if (mimeType == QLatin1String("text/plain") && out.plainBody.isEmpty()) { + out.plainBody = QString::fromUtf8(decodePart(part)); + } else if (mimeType == QLatin1String("text/html") && out.htmlBody.isEmpty()) { + out.htmlBody = QString::fromUtf8(decodePart(part)); + } +} + +} // namespace + +QString Attachment::safeFilename() const +{ + // Reduce to a basename: QFileInfo handles '/', and backslashes are stripped + // explicitly because a Windows-authored name can carry them. + QString name = filename; + name.replace(QLatin1Char('\\'), QLatin1Char('/')); + name = QFileInfo(name).fileName(); + + // A name of "..", "." or empty leaves nothing usable. + if (name.isEmpty() || name == QLatin1String(".") || name == QLatin1String("..")) + return QStringLiteral("attachment-%1").arg( + QUuid::createUuid().toString(QUuid::Id128).left(8)); + + return name; +} + +QString Attachment::saveTo(const QString &directory, QString *error) const +{ + const QDir dir(directory); + const QString target = dir.absoluteFilePath(safeFilename()); + + // Belt and braces: confirm the resolved path really is inside directory, + // so a future change to safeFilename() cannot silently reintroduce escape. + const QString canonicalDir = QDir(directory).absolutePath(); + if (!QFileInfo(target).absolutePath().startsWith(canonicalDir)) { + if (error) + *error = QStringLiteral("Refusing to write outside %1").arg(canonicalDir); + return {}; + } + + QFile file(target); + if (!file.open(QIODevice::WriteOnly)) { + if (error) + *error = file.errorString(); + return {}; + } + file.write(data); + file.close(); + return target; +} + +MimeParser::MimeParser() +{ + ensureGMimeInit(); +} + +ParsedMessage MimeParser::parse(const QString &filePath) const +{ + ParsedMessage out; + + FILE *fp = fopen(filePath.toLocal8Bit().constData(), "r"); + if (!fp) { + out.error = QStringLiteral("Cannot open %1").arg(filePath); + return out; + } + + GMimeStream *stream = g_mime_stream_file_new(fp); + GMimeParser *parser = g_mime_parser_new_with_stream(stream); + GMimeMessage *message = g_mime_parser_construct_message(parser, nullptr); + + g_object_unref(parser); + g_object_unref(stream); + + if (!message) { + out.error = QStringLiteral("Cannot parse %1").arg(filePath); + return out; + } + + out.subject = QString::fromUtf8( + g_mime_message_get_subject(message) ?: ""); + out.from = headerText(message, "From"); + out.to = headerText(message, "To"); + out.cc = headerText(message, "Cc"); + out.date = headerText(message, "Date"); + out.messageId = QString::fromUtf8( + g_mime_message_get_message_id(message) ?: ""); + + GMimeObject *body = g_mime_message_get_mime_part(message); + if (body) + collectParts(body, out); + + g_object_unref(message); + + out.ok = true; + return out; +} diff --git a/src/mimeparser.h b/src/mimeparser.h new file mode 100644 index 0000000..27a239f --- /dev/null +++ b/src/mimeparser.h @@ -0,0 +1,64 @@ +#pragma once + +#include <QByteArray> +#include <QHash> +#include <QList> +#include <QString> + +/// An inline part referenced by a cid: URL from the HTML body. +struct InlinePart +{ + QString mimeType; + QByteArray data; +}; + +struct Attachment +{ + QString filename; ///< As it appeared in the message. Untrusted. + QString mimeType; + QByteArray data; + + /// filename reduced to a basename safe to join onto a directory. + /// Attacker-controlled input: a filename may contain path separators or + /// "..", so anything that could escape the target directory is stripped. + /// Returns a generated name when nothing usable remains. + QString safeFilename() const; + + /// Writes the attachment into directory. Returns the full path written, or + /// an empty string on failure with *error set. + QString saveTo(const QString &directory, QString *error) const; +}; + +struct ParsedMessage +{ + bool ok = false; + QString error; + + QString subject; + QString from; + QString to; + QString cc; + QString date; + QString messageId; + + QString plainBody; + QString htmlBody; + + QHash<QString, InlinePart> inlineParts; ///< Keyed by Content-ID, no <>. + QList<Attachment> attachments; + + bool hasHtml() const { return !htmlBody.isEmpty(); } +}; + +/// Parses a single message file using GMime. +/// +/// Hand-rolling this would mean reimplementing RFC 2047 encoded words, RFC 2231 +/// parameter continuations, transfer encodings, and charset conversion, plus +/// tolerance for malformed real-world mail. +class MimeParser +{ +public: + MimeParser(); + + ParsedMessage parse(const QString &filePath) const; +}; |
