X-Envelope-From: notmuch-bounces@notmuchmail.org  Sat Aug 15 14:12:09 2026
Return-Path: <notmuch-bounces@notmuchmail.org>
X-Original-To: nmbug@yantan.tethera.net
Delivered-To: nmbug@yantan.tethera.net
Received: from yantan.tethera.net (localhost [127.0.0.1])
	by mail.notmuchmail.org (Postfix) with ESMTP id 8CE745F8A1;
	Sat, 15 Aug 2026 14:12:09 +0000 (UTC)
Received: from mail1.khirnov.net (mail1.khirnov.net [IPv6:2a00:c500:561:206::5])
	by mail.notmuchmail.org (Postfix) with ESMTPS id 99DCC5F885
	for <notmuch@notmuchmail.org>; Sat, 15 Aug 2026 14:12:05 +0000 (UTC)
Authentication-Results: mail1.khirnov.net;
	dkim=pass (2048-bit key; unprotected) header.d=khirnov.net header.i=@khirnov.net header.a=rsa-sha256 header.s=mail header.b=QxIMhEeC;
	dkim-atps=neutral
Received: from localhost (mail1.khirnov.net [IPv6:::1])
	by mail1.khirnov.net (Postfix) with ESMTP id A15BC1FB1
	for <notmuch@notmuchmail.org>; Sat, 15 Aug 2026 16:02:47 +0200 (CEST)
Received: from mail1.khirnov.net ([IPv6:::1])
 by localhost (mail1.khirnov.net [IPv6:::1]) (amavis, port 10024) with ESMTP
 id IY1tNkj4Dwz6 for <notmuch@notmuchmail.org>;
 Sat, 15 Aug 2026 16:02:47 +0200 (CEST)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=khirnov.net; s=mail;
	t=1786802561; bh=D8yPYoIlRLMiRdamhw4xD8A7CYm/crNiZweY/GrWe8M=;
	h=From:To:Subject:Date:In-Reply-To:References:From;
	b=QxIMhEeCvijxrMB7AO1F/vgyAUmrMUGo1s+urHALTWbflyYWFCLbtXYsDypm/ujbS
	 V9lAdicirBmaSNavCN9vvKCxsXz800SfX1NqMsrHMMV6K2j27X2MFpL1hJCSaIrHhs
	 WmhOylkxiMpj9FRU5lwxXAovwxedCXfm0AGCn4dus6fe0Pxs0isGHmv7bEBzxDxACo
	 X03jQ+g6VBiLoPJLRk31AKhLJceTmfcreeuF2lBF948K67WIZfnGUFBdIS8rYPUuyJ
	 K6JjJhFvPvUFDaYMFmDwBtTjUwN1juq5Z37s51modEoegsBI569EfSBqDtNGtIQRql
	 u3YLV6lhEsdNQ==
Received: from dev0.khirnov.net (dev0.khirnov.net [IPv6:2a00:c500:561:201::6])
	(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
	 key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256
	 client-signature RSA-PSS (2048 bits) client-digest SHA256)
	(Client CN "dev0.khirnov.net", Issuer "smtp.khirnov.net SMTP CA" (verified OK))
	by mail1.khirnov.net (Postfix) with ESMTPS id 9D35D1FA3
	for <notmuch@notmuchmail.org>; Sat, 15 Aug 2026 16:02:40 +0200 (CEST)
Received: by dev0.khirnov.net (Postfix, from userid 1000)
	id 88E2A402858; Sat, 15 Aug 2026 16:02:40 +0200 (CEST)
From: Anton Khirnov <anton@khirnov.net>
To: notmuch@notmuchmail.org
Subject: [PATCH v5 1/6] Add API for filtering attachments with an external program.
Date: Sat, 15 Aug 2026 16:01:23 +0200
Message-ID: <20260815140127.26199-3-anton@khirnov.net>
X-Mailer: git-send-email 2.47.3
In-Reply-To: <20260815140127.26199-2-anton@khirnov.net>
References: <20260815140127.26199-2-anton@khirnov.net>
MIME-Version: 1.0
Message-ID-Hash: A6RR5QMWWK6MMPRRDQQPZ3OAIDCGPMFP
X-Message-ID-Hash: A6RR5QMWWK6MMPRRDQQPZ3OAIDCGPMFP
X-MailFrom: anton@khirnov.net
X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; emergency; loop; banned-address; member-moderation; header-match-notmuch.notmuchmail.org-0; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header
X-Mailman-Version: 3.3.3
Precedence: list
List-Id: "Use and development of the notmuch mail system." <notmuch.notmuchmail.org>
List-Help: <mailto:notmuch-request@notmuchmail.org?subject=help>
List-Owner: <mailto:notmuch-owner@notmuchmail.org>
List-Post: <mailto:notmuch@notmuchmail.org>
List-Subscribe: <mailto:notmuch-join@notmuchmail.org>
List-Unsubscribe: <mailto:notmuch-leave@notmuchmail.org>
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: 7bit

This is just documentation and the config/indexopts interfaces, actual
implementation will be added in following commits.

A basic performance test is also added here, to easily verify that the
following changes do not cost anything when not enabled.
---
 bindings/python-cffi/notmuch2/_build.py     |  5 +++
 bindings/python-cffi/notmuch2/_database.py  | 21 +++++++++++++
 bindings/python-cffi/tests/test_database.py | 16 ++++++++++
 doc/man1/notmuch-config.rst                 | 27 ++++++++++++++++
 lib/config.cc                               |  3 ++
 lib/indexopts.c                             | 34 +++++++++++++++++++++
 lib/notmuch.h                               | 23 ++++++++++++++
 performance-test/T09-attachment-index.sh    | 18 +++++++++++
 test/T590-libconfig.sh                      |  5 +++
 9 files changed, 152 insertions(+)
 create mode 100755 performance-test/T09-attachment-index.sh

diff --git a/bindings/python-cffi/notmuch2/_build.py b/bindings/python-cffi/notmuch2/_build.py
index 2f3152c6..f918f96f 100644
--- a/bindings/python-cffi/notmuch2/_build.py
+++ b/bindings/python-cffi/notmuch2/_build.py
@@ -328,6 +328,11 @@ ffibuilder.cdef(
                                           notmuch_decryption_policy_t decrypt_policy);
     notmuch_decryption_policy_t
     notmuch_indexopts_get_decrypt_policy (const notmuch_indexopts_t *indexopts);
+    notmuch_status_t
+    notmuch_indexopts_set_filter (notmuch_indexopts_t *indexopts,
+                                  const char *filter_cmd);
+    const char *
+    notmuch_indexopts_get_filter (const notmuch_indexopts_t *indexopts);
     void
     notmuch_indexopts_destroy (notmuch_indexopts_t *options);
 
diff --git a/bindings/python-cffi/notmuch2/_database.py b/bindings/python-cffi/notmuch2/_database.py
index ba389a42..78a43d72 100644
--- a/bindings/python-cffi/notmuch2/_database.py
+++ b/bindings/python-cffi/notmuch2/_database.py
@@ -878,3 +878,24 @@ class IndexOptions(base.NotmuchObject):
             self._opts_p, val.value)
         if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
             raise errors.NotmuchError(ret)
+
+    @property
+    def filter_cmd(self):
+        """Filtering program to extract text from non-text MIME parts.
+
+        CAUTION: improper use of this option may lead to remote code
+        execution on the user's machine. See the `index.filter` section
+        in :any:`notmuch-config(1)` for details. Make sure you read and
+        understand it before setting this property.
+
+        The value is a string that is split in a shell-like manner and
+        executed.
+        """
+        raw = capi.lib.notmuch_indexopts_get_filter(self._opts_p)
+        return base.BinString.from_cffi(raw)
+
+    @filter_cmd.setter
+    def filter_cmd(self, val):
+        ret = capi.lib.notmuch_indexopts_set_filter(self._opts_p, val)
+        if ret != capi.lib.NOTMUCH_STATUS_SUCCESS:
+            raise errors.NotmuchError(ret)
diff --git a/bindings/python-cffi/tests/test_database.py b/bindings/python-cffi/tests/test_database.py
index ece838ee..6173d8df 100644
--- a/bindings/python-cffi/tests/test_database.py
+++ b/bindings/python-cffi/tests/test_database.py
@@ -114,6 +114,22 @@ class TestCreate:
         assert db.needs_upgrade in (True, False)
 
 
+class TestIndexOptions:
+
+    @pytest.fixture
+    def indexopts(self, db):
+        return db.default_indexopts()
+
+    def test_filter_cmd(self, indexopts):
+        filter_cmd = b'some filter command'
+        indexopts.filter_cmd = filter_cmd
+        assert bytes(indexopts.filter_cmd) == filter_cmd
+
+    def test_filter_cmd_invalid(self, indexopts):
+        with pytest.raises(TypeError):
+            indexopts.filter_cmd = {}
+
+
 class TestAtomic:
 
     def test_exit_early(self, db):
diff --git a/doc/man1/notmuch-config.rst b/doc/man1/notmuch-config.rst
index 6a63e457..b8c13462 100644
--- a/doc/man1/notmuch-config.rst
+++ b/doc/man1/notmuch-config.rst
@@ -154,6 +154,33 @@ paths are presumed relative to `$HOME` for items in section
 
    History: This configuration value was introduced in notmuch 0.38.
 
+.. nmconfig:: index.filter
+
+   Filtering program to convert non-text MIME parts to a text
+   representation for indexing. Will only be applied to those parts that
+   match ``index.as_text``.
+
+   CAUTION: It is very common for hostile actors to send emails with
+   crafted attachments that exploit bugs in common parsing libraries. It
+   is thus IMPERATIVE that your filtering program uses some sort of a
+   sandboxing mechanism, so that it cannot be subverted to attack your
+   system or steal your data.
+
+   The filter is a commandline split in a POSIX shell-like manner (without
+   actually invoking the shell, so shell expansions are not performed and shell
+   constructs are not allowed) and executed. The payload of the MIME part to be
+   filtered will be supplied on its `stdin`, it is expected to write the text
+   output to its `stdout`. The following environment variables will be set:
+
+   * :envvar:`NOTMUCH_FILTER_MIME_TYPE` - the ``type/subtype`` part of the
+     "content-type" header
+   * :envvar:`NOTMUCH_FILTER_FILENAME` - the file name associated with the
+     attachment, if present
+   * :envvar:`NOTMUCH_FILTER_MESSAGE_ID` - the message ID, without enclosing
+     angle brackets <>
+
+   History: This configuration value was introduced in notmuch 0.41.
+
 .. nmconfig:: index.decrypt
 
     Policy for decrypting encrypted messages during indexing.  Must be
diff --git a/lib/config.cc b/lib/config.cc
index d231c893..9bbbe467 100644
--- a/lib/config.cc
+++ b/lib/config.cc
@@ -614,6 +614,8 @@ _notmuch_config_key_to_string (notmuch_config_key_t key)
 	return "git.metadata_prefix";
     case NOTMUCH_CONFIG_GIT_REF:
 	return "git.ref";
+    case NOTMUCH_CONFIG_INDEX_FILTER:
+	return "index.filter";
     default:
 	return NULL;
     }
@@ -675,6 +677,7 @@ _notmuch_config_default (notmuch_database_t *notmuch, notmuch_config_key_t key)
     case NOTMUCH_CONFIG_HOOK_DIR:
     case NOTMUCH_CONFIG_BACKUP_DIR:
     case NOTMUCH_CONFIG_OTHER_EMAIL:
+    case NOTMUCH_CONFIG_INDEX_FILTER:
 	return NULL;
     default:
     case NOTMUCH_CONFIG_LAST:
diff --git a/lib/indexopts.c b/lib/indexopts.c
index da378700..8e497ba8 100644
--- a/lib/indexopts.c
+++ b/lib/indexopts.c
@@ -22,6 +22,8 @@
 
 struct _notmuch_indexopts {
     _notmuch_crypto_t crypto;
+
+    char *filter_cmd;
 };
 
 notmuch_indexopts_t *
@@ -53,6 +55,21 @@ notmuch_database_get_default_indexopts (notmuch_database_t *db)
     }
 
     free (decrypt_policy);
+
+    char *filter_cmd;
+
+    err = notmuch_database_get_config (db, "index.filter", &filter_cmd);
+    if (err)
+	goto FAIL;
+
+    if (filter_cmd && *filter_cmd) {
+	ret->filter_cmd = talloc_strdup (ret, filter_cmd);
+	free (filter_cmd);
+	if (!ret->filter_cmd)
+	    goto FAIL;
+    } else
+	free (filter_cmd);
+
     return ret;
 
 FAIL:
@@ -78,6 +95,23 @@ notmuch_indexopts_get_decrypt_policy (const notmuch_indexopts_t *indexopts)
     return indexopts->crypto.decrypt;
 }
 
+notmuch_status_t
+notmuch_indexopts_set_filter (notmuch_indexopts_t *indexopts,
+			      const char *filter_cmd)
+{
+    talloc_free (indexopts->filter_cmd);
+    indexopts->filter_cmd = talloc_strdup (indexopts, filter_cmd);
+    if (!indexopts->filter_cmd)
+	return NOTMUCH_STATUS_OUT_OF_MEMORY;
+    return NOTMUCH_STATUS_SUCCESS;
+}
+
+const char *
+notmuch_indexopts_get_filter (const notmuch_indexopts_t *indexopts)
+{
+    return indexopts ? indexopts->filter_cmd : NULL;
+}
+
 void
 notmuch_indexopts_destroy (notmuch_indexopts_t *indexopts)
 {
diff --git a/lib/notmuch.h b/lib/notmuch.h
index c403a348..33c7ed8c 100644
--- a/lib/notmuch.h
+++ b/lib/notmuch.h
@@ -2644,6 +2644,7 @@ typedef enum {
     NOTMUCH_CONFIG_GIT_FAIL_ON_MISSING,
     NOTMUCH_CONFIG_GIT_METADATA_PREFIX,
     NOTMUCH_CONFIG_GIT_REF,
+    NOTMUCH_CONFIG_INDEX_FILTER,
     NOTMUCH_CONFIG_LAST
 } notmuch_config_key_t;
 
@@ -2931,6 +2932,28 @@ notmuch_indexopts_get_decrypt_policy (const notmuch_indexopts_t *indexopts);
  *
  * @since libnotmuch 5.1 (notmuch 0.26)
  */
+
+/**
+ * Set a filtering program to extract text from non-text MIME parts.
+ *
+ * CAUTION: improper use of this option may lead to remote code
+ * execution on the user's machine. See the `index.filter` section in
+ * `notmuch-config(1)` for details. Make sure you read and understand
+ * it before calling this function.
+ */
+notmuch_status_t
+notmuch_indexopts_set_filter (notmuch_indexopts_t *indexopts,
+			      const char *filter_cmd);
+
+/**
+ * Return currently configured filtering program, or NULL if one is not
+ * configured.
+ *
+ * see notmuch_indexopts_set_filter
+ */
+const char *
+notmuch_indexopts_get_filter (const notmuch_indexopts_t *indexopts);
+
 void
 notmuch_indexopts_destroy (notmuch_indexopts_t *options);
 
diff --git a/performance-test/T09-attachment-index.sh b/performance-test/T09-attachment-index.sh
new file mode 100755
index 00000000..e2a5b4e3
--- /dev/null
+++ b/performance-test/T09-attachment-index.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+
+test_description='attachment content indexing'
+
+. $(dirname "$0")/perf-test-lib.sh || exit 1
+
+time_start
+
+notmuch config set index.as_text ".*"
+
+time_run 'reindex w/o filter' "notmuch reindex tag:attachment"
+time_run 'reindex w/o filter' "notmuch reindex tag:attachment"
+
+notmuch config set index.filter "/bin/true"
+
+time_run 'reindex with filter' "notmuch reindex tag:attachment"
+
+time_done
diff --git a/test/T590-libconfig.sh b/test/T590-libconfig.sh
index e893fd1f..554da268 100755
--- a/test/T590-libconfig.sh
+++ b/test/T590-libconfig.sh
@@ -446,6 +446,7 @@ cat <<'EOF' >EXPECTED
 16: 'true'
 17: '_notmuch_metadata'
 18: 'refs/heads/master'
+19: 'NULL'
 == stderr ==
 EOF
 unset MAILDIR
@@ -734,6 +735,7 @@ notmuch config set search.authors_matched_separator "| "
 notmuch config set search.authors_separator ", "
 notmuch config set new.ignore "sekrit_junk"
 notmuch config set index.as_text "text/"
+notmuch config set index.filter "filter"
 cat c_head2 - c_tail <<'EOF' | test_C ${MAIL_DIR} %NULL% %NULL%
 {
     notmuch_config_key_t key;
@@ -766,6 +768,7 @@ cat <<'EOF' >EXPECTED
 16: 'true'
 17: '_notmuch_metadata'
 18: 'refs/heads/master'
+19: 'filter'
 == stderr ==
 EOF
 test_expect_equal_file EXPECTED OUTPUT
@@ -806,6 +809,7 @@ cat <<'EOF' >EXPECTED
 16: 'true'
 17: '_notmuch_metadata'
 18: 'refs/heads/master'
+19: 'NULL'
 == stderr ==
 EOF
 test_expect_equal_file EXPECTED OUTPUT.clean
@@ -881,6 +885,7 @@ git.fail_on_missing true
 git.metadata_prefix _notmuch_metadata
 git.ref refs/heads/master
 index.as_text text/
+index.filter filter
 key with spaces value, with, spaces!
 maildir.synchronize_flags true
 new.ignore sekrit_junk
-- 
2.47.3

_______________________________________________
notmuch mailing list -- notmuch@notmuchmail.org
To unsubscribe send an email to notmuch-leave@notmuchmail.org
