summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-02 17:27:07 +0200
committerDanilo M. <danix@danix.xyz>2026-08-02 17:27:07 +0200
commit35d3e4ff127b0213fa4d34f630c4a019e533a4df (patch)
tree7cf50ed64ad5886ff909ab8ab2a26d99b2d90947
parentb61ce428d81871fbbc6261f274f20785db9d1c38 (diff)
downloadqtmaildir-35d3e4ff127b0213fa4d34f630c4a019e533a4df.tar.gz
qtmaildir-35d3e4ff127b0213fa4d34f630c4a019e533a4df.zip
feat: add MimeParser with GMime and safe attachment naming
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/mimeparser.cpp218
-rw-r--r--src/mimeparser.h64
-rw-r--r--tests/CMakeLists.txt3
-rw-r--r--tests/fixtures/alternative.eml16
-rw-r--r--tests/fixtures/attachment.eml18
-rw-r--r--tests/fixtures/encoded_subject.eml7
-rw-r--r--tests/fixtures/hostile_filename.eml17
-rw-r--r--tests/fixtures/inline_image.eml19
-rw-r--r--tests/fixtures/plain.eml12
-rw-r--r--tests/fixtures/truncated.eml11
-rw-r--r--tests/test_mimeparser.cpp171
12 files changed, 557 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;
+};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index bec7621..13cb5da 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -7,3 +7,6 @@ endfunction()
add_qtmaildir_test(keymap)
add_qtmaildir_test(config)
+add_qtmaildir_test(mimeparser)
+target_compile_definitions(test_mimeparser PRIVATE
+ FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures")
diff --git a/tests/fixtures/alternative.eml b/tests/fixtures/alternative.eml
new file mode 100644
index 0000000..1c35d79
--- /dev/null
+++ b/tests/fixtures/alternative.eml
@@ -0,0 +1,16 @@
+From: Alice <alice@example.org>
+Subject: Both parts
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <alt-1@example.org>
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="BOUND"
+
+--BOUND
+Content-Type: text/plain; charset=utf-8
+
+plain version
+--BOUND
+Content-Type: text/html; charset=utf-8
+
+<html><body><p>html version</p></body></html>
+--BOUND--
diff --git a/tests/fixtures/attachment.eml b/tests/fixtures/attachment.eml
new file mode 100644
index 0000000..a0410e4
--- /dev/null
+++ b/tests/fixtures/attachment.eml
@@ -0,0 +1,18 @@
+From: Alice <alice@example.org>
+Subject: With attachment
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <att-1@example.org>
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="MIX"
+
+--MIX
+Content-Type: text/plain; charset=utf-8
+
+see attached
+--MIX
+Content-Type: text/plain; charset=utf-8; name="notes.txt"
+Content-Disposition: attachment; filename="notes.txt"
+Content-Transfer-Encoding: quoted-printable
+
+caf=C3=A9 notes
+--MIX--
diff --git a/tests/fixtures/encoded_subject.eml b/tests/fixtures/encoded_subject.eml
new file mode 100644
index 0000000..7dcb6f8
--- /dev/null
+++ b/tests/fixtures/encoded_subject.eml
@@ -0,0 +1,7 @@
+From: =?utf-8?B?w4RsaWNl?= <alice@example.org>
+Subject: =?utf-8?Q?Caf=C3=A9_meeting?=
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <enc-1@example.org>
+Content-Type: text/plain; charset=utf-8
+
+body
diff --git a/tests/fixtures/hostile_filename.eml b/tests/fixtures/hostile_filename.eml
new file mode 100644
index 0000000..4dc79b6
--- /dev/null
+++ b/tests/fixtures/hostile_filename.eml
@@ -0,0 +1,17 @@
+From: Attacker <bad@example.org>
+Subject: Hostile attachment name
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <evil-1@example.org>
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="EVIL"
+
+--EVIL
+Content-Type: text/plain; charset=utf-8
+
+body
+--EVIL
+Content-Type: text/plain; name="../../../../tmp/pwned.txt"
+Content-Disposition: attachment; filename="../../../../tmp/pwned.txt"
+
+owned
+--EVIL--
diff --git a/tests/fixtures/inline_image.eml b/tests/fixtures/inline_image.eml
new file mode 100644
index 0000000..a9dd24b
--- /dev/null
+++ b/tests/fixtures/inline_image.eml
@@ -0,0 +1,19 @@
+From: Alice <alice@example.org>
+Subject: Inline image
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <cid-1@example.org>
+MIME-Version: 1.0
+Content-Type: multipart/related; boundary="REL"
+
+--REL
+Content-Type: text/html; charset=utf-8
+
+<html><body><img src="cid:logo@example.org"></body></html>
+--REL
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-ID: <logo@example.org>
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9
+awAAAABJRU5ErkJggg==
+--REL--
diff --git a/tests/fixtures/plain.eml b/tests/fixtures/plain.eml
new file mode 100644
index 0000000..81f06b4
--- /dev/null
+++ b/tests/fixtures/plain.eml
@@ -0,0 +1,12 @@
+From: Alice <alice@example.org>
+To: Bob <bob@example.net>
+Subject: Plain hello
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <plain-1@example.org>
+Content-Type: text/plain; charset=utf-8
+
+Hello Bob.
+
+> quoted line
+Regards,
+Alice
diff --git a/tests/fixtures/truncated.eml b/tests/fixtures/truncated.eml
new file mode 100644
index 0000000..912a522
--- /dev/null
+++ b/tests/fixtures/truncated.eml
@@ -0,0 +1,11 @@
+From: Alice <alice@example.org>
+Subject: Truncated
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <trunc-1@example.org>
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="CUT"
+
+--CUT
+Content-Type: text/plain; charset=utf-8
+
+this part never closes
diff --git a/tests/test_mimeparser.cpp b/tests/test_mimeparser.cpp
new file mode 100644
index 0000000..72bdff7
--- /dev/null
+++ b/tests/test_mimeparser.cpp
@@ -0,0 +1,171 @@
+#include <QtTest>
+#include <QTemporaryDir>
+#include <QDir>
+#include "mimeparser.h"
+
+class TestMimeParser : public QObject
+{
+ Q_OBJECT
+private slots:
+ void initTestCase();
+
+ void parsesPlainText();
+ void prefersHtmlWhenAvailable();
+ void fallsBackToPlainWhenHtmlDisabled();
+ void collectsInlineCidParts();
+ void decodesQuotedPrintableAttachment();
+ void decodesEncodedHeaders();
+ void malformedMessageDoesNotCrash();
+ void missingFileIsReported();
+ void hostileFilenameIsSanitised();
+ void savedAttachmentMatchesBytes();
+
+private:
+ QString fixture(const QString &name) const
+ { return m_fixtureDir + QLatin1Char('/') + name; }
+
+ QString m_fixtureDir;
+};
+
+void TestMimeParser::initTestCase()
+{
+ // FIXTURE_DIR is defined by CMake so the test can run from any cwd.
+ m_fixtureDir = QStringLiteral(FIXTURE_DIR);
+ QVERIFY2(QDir(m_fixtureDir).exists(), "fixture directory missing");
+}
+
+void TestMimeParser::parsesPlainText()
+{
+ MimeParser parser;
+ const ParsedMessage msg = parser.parse(fixture(QStringLiteral("plain.eml")));
+
+ QVERIFY(msg.ok);
+ QCOMPARE(msg.subject, QStringLiteral("Plain hello"));
+ QCOMPARE(msg.from, QStringLiteral("Alice <alice@example.org>"));
+ QVERIFY(msg.plainBody.contains(QStringLiteral("Hello Bob.")));
+ QVERIFY(msg.htmlBody.isEmpty());
+ QVERIFY(msg.attachments.isEmpty());
+}
+
+void TestMimeParser::prefersHtmlWhenAvailable()
+{
+ MimeParser parser;
+ const ParsedMessage msg =
+ parser.parse(fixture(QStringLiteral("alternative.eml")));
+
+ QVERIFY(msg.ok);
+ QVERIFY(msg.htmlBody.contains(QStringLiteral("html version")));
+ // The plain alternative is kept so the user can toggle to it.
+ QVERIFY(msg.plainBody.contains(QStringLiteral("plain version")));
+ QVERIFY(msg.hasHtml());
+}
+
+void TestMimeParser::fallsBackToPlainWhenHtmlDisabled()
+{
+ MimeParser parser;
+ const ParsedMessage msg = parser.parse(fixture(QStringLiteral("plain.eml")));
+
+ QVERIFY(!msg.hasHtml());
+ QVERIFY(!msg.plainBody.isEmpty());
+}
+
+void TestMimeParser::collectsInlineCidParts()
+{
+ MimeParser parser;
+ const ParsedMessage msg =
+ parser.parse(fixture(QStringLiteral("inline_image.eml")));
+
+ QVERIFY(msg.ok);
+ QCOMPARE(msg.inlineParts.size(), 1);
+ // Content-ID angle brackets are stripped so it matches the cid: URL body.
+ QVERIFY(msg.inlineParts.contains(QStringLiteral("logo@example.org")));
+
+ const InlinePart part = msg.inlineParts.value(QStringLiteral("logo@example.org"));
+ QCOMPARE(part.mimeType, QStringLiteral("image/png"));
+ // Decoded 1x1 PNG starts with the PNG magic bytes.
+ QVERIFY(part.data.startsWith(QByteArray("\x89PNG", 4)));
+}
+
+void TestMimeParser::decodesQuotedPrintableAttachment()
+{
+ MimeParser parser;
+ const ParsedMessage msg =
+ parser.parse(fixture(QStringLiteral("attachment.eml")));
+
+ QVERIFY(msg.ok);
+ QCOMPARE(msg.attachments.size(), 1);
+ QCOMPARE(msg.attachments.first().filename, QStringLiteral("notes.txt"));
+ QCOMPARE(QString::fromUtf8(msg.attachments.first().data),
+ QStringLiteral("café notes"));
+}
+
+void TestMimeParser::decodesEncodedHeaders()
+{
+ MimeParser parser;
+ const ParsedMessage msg =
+ parser.parse(fixture(QStringLiteral("encoded_subject.eml")));
+
+ QVERIFY(msg.ok);
+ QCOMPARE(msg.subject, QStringLiteral("Café meeting"));
+ QVERIFY(msg.from.contains(QStringLiteral("Älice")));
+}
+
+void TestMimeParser::malformedMessageDoesNotCrash()
+{
+ MimeParser parser;
+ const ParsedMessage msg =
+ parser.parse(fixture(QStringLiteral("truncated.eml")));
+
+ // GMime is tolerant: it recovers the headers and whatever body it found.
+ // The requirement is only that parsing terminates and reports something.
+ QCOMPARE(msg.subject, QStringLiteral("Truncated"));
+}
+
+void TestMimeParser::missingFileIsReported()
+{
+ MimeParser parser;
+ const ParsedMessage msg =
+ parser.parse(fixture(QStringLiteral("does_not_exist.eml")));
+
+ QVERIFY(!msg.ok);
+ QVERIFY(!msg.error.isEmpty());
+}
+
+void TestMimeParser::hostileFilenameIsSanitised()
+{
+ MimeParser parser;
+ const ParsedMessage msg =
+ parser.parse(fixture(QStringLiteral("hostile_filename.eml")));
+
+ QVERIFY(msg.ok);
+ QCOMPARE(msg.attachments.size(), 1);
+
+ // The raw header value is preserved for display...
+ QVERIFY(msg.attachments.first().filename.contains(QStringLiteral("..")));
+ // ...but the name used on disk is reduced to a basename.
+ QCOMPARE(msg.attachments.first().safeFilename(), QStringLiteral("pwned.txt"));
+}
+
+void TestMimeParser::savedAttachmentMatchesBytes()
+{
+ MimeParser parser;
+ const ParsedMessage msg =
+ parser.parse(fixture(QStringLiteral("attachment.eml")));
+ QVERIFY(msg.ok);
+
+ QTemporaryDir dir;
+ QString error;
+ const QString written =
+ msg.attachments.first().saveTo(dir.path(), &error);
+
+ QVERIFY2(!written.isEmpty(), qPrintable(error));
+ // Never escapes the target directory.
+ QVERIFY(written.startsWith(dir.path()));
+
+ QFile f(written);
+ QVERIFY(f.open(QIODevice::ReadOnly));
+ QCOMPARE(f.readAll(), msg.attachments.first().data);
+}
+
+QTEST_MAIN(TestMimeParser)
+#include "test_mimeparser.moc"