summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-20 18:15:29 +0200
committerDanilo M. <danix@danix.xyz>2026-08-20 18:15:29 +0200
commit4f6d1ecb352ad8bba850ca5611ad4881d113bf52 (patch)
tree3e78b366056f925456a7c43420e8ec45f79cc245
parentc50bea78e036518ce1a2a3eb899bbb5e305affea (diff)
downloadqtmaildir-4f6d1ecb352ad8bba850ca5611ad4881d113bf52.tar.gz
qtmaildir-4f6d1ecb352ad8bba850ca5611ad4881d113bf52.zip
feat(compose): render markdown bodies with cmark-gfm, item 123
The composer's body is markdown and the text/html part is generated from it. cmark-gfm rather than plain cmark for autolink: under CommonMark a bare URL in a mail body is not a link, and in mail it is expected to be clickable. Three extensions are enabled and tables are deliberately not, since they render badly across mail clients whoever generates them. Raw HTML in the input is suppressed with CMARK_OPT_SAFE: the body is the user's own text, but a body that can inject markup into its own generated HTML part is a sharp edge with no upside. The build needs TWO lookups. Only the core library ships a pkg-config file; libcmark-gfm-extensions has none and is located with find_library, the way notmuch already is. All three extensions live in that second library, so finding only the first produces a build that compiles and silently renders plain CommonMark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015muoUo2GdxmBDSp5vjYcbE
-rw-r--r--CMakeLists.txt17
-rw-r--r--src/CMakeLists.txt4
-rw-r--r--src/markdownrenderer.cpp92
-rw-r--r--src/markdownrenderer.h38
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_markdownrenderer.cpp121
6 files changed, 272 insertions, 1 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 97a1d90..47b0df6 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -34,6 +34,23 @@ message(STATUS "Found notmuch: ${NOTMUCH_LIBRARY}")
find_package(PkgConfig REQUIRED)
pkg_check_modules(GMIME REQUIRED IMPORTED_TARGET gmime-3.0)
+# cmark-gfm renders the composer's markdown body into the HTML part.
+#
+# TWO lookups, not one, and this is the trap: only the CORE library ships a
+# pkg-config file. `libcmark-gfm-extensions` has none (verified 2026-08-20 on
+# Slackware, cmark-gfm-0.29.0.gfm.13), so it is located by hand exactly as
+# notmuch is. The extensions library is not optional here: autolink,
+# strikethrough and tasklist all live in it, and without it a bare URL in a
+# mail body is not a link.
+pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm)
+find_library(CMARK_GFM_EXTENSIONS_LIBRARY NAMES cmark-gfm-extensions)
+if(NOT CMARK_GFM_EXTENSIONS_LIBRARY)
+ message(FATAL_ERROR
+ "libcmark-gfm-extensions not found. It ships with cmark-gfm but has "
+ "no pkg-config file; it provides autolink, strikethrough and tasklist.")
+endif()
+message(STATUS "Found cmark-gfm extensions: ${CMARK_GFM_EXTENSIONS_LIBRARY}")
+
# The version lives only in the project() call above; version.h is generated
# from it so no source file repeats the literal.
configure_file(
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index b63ff3e..6108696 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -2,6 +2,7 @@ add_library(qtmaildir_lib STATIC
keymap.cpp
config.cpp
mimeparser.cpp
+ markdownrenderer.cpp
requestinterceptor.cpp
htmlbuilder.cpp
cidschemehandler.cpp
@@ -36,7 +37,8 @@ target_include_directories(qtmaildir_lib
target_link_libraries(qtmaildir_lib
PUBLIC Qt6::Widgets Qt6::Svg Qt6::WebEngineWidgets PkgConfig::GMIME
- ${NOTMUCH_LIBRARY})
+ ${NOTMUCH_LIBRARY} PkgConfig::CMARK_GFM
+ ${CMARK_GFM_EXTENSIONS_LIBRARY})
# resources.qrc belongs to the executable, not to the static library. A qrc
# compiled into a .a registers itself from a global initialiser, and the linker
diff --git a/src/markdownrenderer.cpp b/src/markdownrenderer.cpp
new file mode 100644
index 0000000..a9b8177
--- /dev/null
+++ b/src/markdownrenderer.cpp
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+
+// cmark-gfm's headers are C and carry no Qt interaction, so the gmime
+// include-order rule does not apply here. They still go first, for consistency
+// with mimeparser.cpp.
+#include <cmark-gfm.h>
+#include <cmark-gfm-core-extensions.h>
+
+#include "markdownrenderer.h"
+
+#include <QByteArray>
+
+#include <cstdlib>
+
+namespace {
+
+/// The extensions this application enables, by cmark-gfm's own names.
+///
+/// `table` is absent deliberately, not by oversight: tables render badly
+/// across mail clients regardless of who generates them. `tagfilter` is absent
+/// because CMARK_OPT_SAFE already suppresses raw HTML wholesale, which is the
+/// stronger measure.
+const char *const kExtensions[] = { "autolink", "strikethrough", "tasklist" };
+
+} // namespace
+
+QString MarkdownRenderer::toHtml(const QString &markdown)
+{
+ if (markdown.isEmpty())
+ return {};
+
+ // Idempotent and required before cmark_find_syntax_extension() can resolve
+ // any name. Calling it per render rather than once at startup keeps this
+ // function free of initialisation order concerns; it is a hash lookup
+ // after the first call.
+ cmark_gfm_core_extensions_ensure_registered();
+
+ // SAFE suppresses raw HTML in the INPUT. It does not escape the output,
+ // which is markup by definition.
+ const int options = CMARK_OPT_DEFAULT | CMARK_OPT_SAFE;
+
+ cmark_parser *parser = cmark_parser_new(options);
+ if (!parser)
+ return {};
+
+ for (const char *name : kExtensions) {
+ // A missing extension is a broken installation rather than a
+ // condition to handle: the library was found by CMake. Skipping it
+ // degrades to plain CommonMark rather than crashing.
+ if (cmark_syntax_extension *extension = cmark_find_syntax_extension(name))
+ cmark_parser_attach_syntax_extension(parser, extension);
+ }
+
+ const QByteArray utf8 = markdown.toUtf8();
+ cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size()));
+
+ cmark_node *document = cmark_parser_finish(parser);
+ if (!document) {
+ cmark_parser_free(parser);
+ return {};
+ }
+
+ // The extension list must be passed to the renderer as well as to the
+ // parser. Passing nullptr here parses the tasklist correctly and then
+ // renders it as a plain list item, which looks like the extension never
+ // worked.
+ char *html = cmark_render_html(document, options,
+ cmark_parser_get_syntax_extensions(parser));
+ const QString result = html ? QString::fromUtf8(html) : QString();
+
+ free(html);
+ cmark_node_free(document);
+ cmark_parser_free(parser);
+
+ return result;
+}
diff --git a/src/markdownrenderer.h b/src/markdownrenderer.h
new file mode 100644
index 0000000..80c52bf
--- /dev/null
+++ b/src/markdownrenderer.h
@@ -0,0 +1,38 @@
+/*
+ * 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 <QString>
+
+/// Renders the composer's markdown body into the HTML part's fragment.
+///
+/// A namespace of free functions rather than a class: there is no state, and
+/// keeping it painter-free and widget-free is what lets the extension
+/// configuration be tested on its own. `MessageBuilder` calls this; nothing
+/// else does.
+namespace MarkdownRenderer {
+
+/// The markdown source as an HTML fragment: no <html>, <head> or <body>.
+///
+/// Three extensions are enabled (autolink, strikethrough, tasklist) and
+/// tables are deliberately not. Raw HTML in the input is suppressed by
+/// CMARK_OPT_SAFE.
+QString toHtml(const QString &markdown);
+
+} // namespace MarkdownRenderer
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index d1d8a29..9f6e851 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -74,3 +74,4 @@ add_qtmaildir_test(translations)
# only as English in a running Italian UI.
target_compile_definitions(test_translations PRIVATE
TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations")
+add_qtmaildir_test(markdownrenderer)
diff --git a/tests/test_markdownrenderer.cpp b/tests/test_markdownrenderer.cpp
new file mode 100644
index 0000000..44f3ccb
--- /dev/null
+++ b/tests/test_markdownrenderer.cpp
@@ -0,0 +1,121 @@
+/*
+ * 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 <QtTest>
+
+#include "markdownrenderer.h"
+
+/// The extension configuration cmark-gfm renders the composer's body with.
+///
+/// No QApplication is needed here: MarkdownRenderer is a pure function over
+/// strings, so QTEST_APPLESS_MAIN avoids pulling in a platform plugin for a
+/// test that has nothing to do with widgets.
+class TestMarkdownRenderer : public QObject
+{
+ Q_OBJECT
+private slots:
+ void commonMarkBasicsRender();
+ void autolinkTurnsABareUrlIntoALink();
+ void strikethroughRenders();
+ void tasklistRenders();
+ void tablesAreNotEnabled();
+ void rawHtmlIsSuppressed();
+ void accentedTextSurvivesAsUtf8();
+ void emptyInputProducesEmptyOutput();
+};
+
+void TestMarkdownRenderer::commonMarkBasicsRender()
+{
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("**bold** *italic* `code`"));
+ QVERIFY2(html.contains(QStringLiteral("<strong>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("<em>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("<code>")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::autolinkTurnsABareUrlIntoALink()
+{
+ // The whole reason cmark-gfm was chosen over plain cmark. Under
+ // CommonMark a bare URL is text, and a bare URL in mail is expected to
+ // be clickable.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("see https://example.org for details"));
+ QVERIFY2(html.contains(QStringLiteral("<a href=\"https://example.org\"")),
+ qPrintable(html));
+}
+
+void TestMarkdownRenderer::strikethroughRenders()
+{
+ const QString html = MarkdownRenderer::toHtml(QStringLiteral("~~gone~~"));
+ QVERIFY2(html.contains(QStringLiteral("<del>gone</del>")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::tasklistRenders()
+{
+ // Known ceiling: many mail clients strip the checkbox, so those
+ // recipients see the item with no marker. The plain part still carries
+ // the literal "- [ ]", so nothing is lost, only the HTML rendering.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("- [ ] todo\n- [x] done"));
+ QVERIFY2(html.contains(QStringLiteral("type=\"checkbox\"")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("checked")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::tablesAreNotEnabled()
+{
+ // Deliberately off: tables render badly across mail clients regardless of
+ // who generates them. The extension EXISTS in the library, so this
+ // asserts a decision rather than a limitation, and would silently start
+ // passing the wrong way if someone attached it "for completeness".
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("| a | b |\n|---|---|\n| 1 | 2 |"));
+ QVERIFY2(!html.contains(QStringLiteral("<table")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("| a | b |")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::rawHtmlIsSuppressed()
+{
+ // CMARK_OPT_SAFE. The body is the user's own text, but a body that can
+ // inject markup into its own generated HTML part is a sharp edge with no
+ // upside.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("<script>alert(1)</script>\n\nafter"));
+ QVERIFY2(!html.contains(QStringLiteral("<script>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("after")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::accentedTextSurvivesAsUtf8()
+{
+ // This user writes Italian, so accented text is every message rather
+ // than an edge case, and a UTF-8 round trip through a C library is
+ // exactly where it would be lost.
+ const QString source = QString::fromUtf8("perch\xC3\xA9 \xC3\xA8 cos\xC3\xAC");
+ const QString html = MarkdownRenderer::toHtml(source);
+ QVERIFY2(html.contains(source), qPrintable(html));
+}
+
+void TestMarkdownRenderer::emptyInputProducesEmptyOutput()
+{
+ // reply_no_quote opens a composer with an empty body and it must not
+ // produce a stray paragraph or crash the renderer.
+ const QString html = MarkdownRenderer::toHtml(QString());
+ QVERIFY2(html.trimmed().isEmpty(), qPrintable(html));
+}
+
+QTEST_APPLESS_MAIN(TestMarkdownRenderer)
+#include "test_markdownrenderer.moc"