aboutsummaryrefslogtreecommitdiffstats
path: root/assets/hooks/post-new
blob: fca31a16085f01625a93851a8db3a7ddebad7205 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
#!/usr/bin/env python3
#
# 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.
"""notmuch post-new hook: auto-tag incoming mail from the shared rule store.

Runs after every `notmuch new`. Reads ~/.config/mailrules/rules.json and
applies each enabled rule scoped to `tag:new`, in stage order, then consumes
the `tag:new` marker.

Rules may add any tag and remove most, but this hook REFUSES to remove `unread`
or `inbox` unattended and skips any rule that asks: see PROTECTED_REMOVALS
below for why, and for the conditions under which that restriction should be
lifted. It is expected to be relaxed once there is a story for confirming such
a rule before it runs.

Requires `new` in [new] tags= in ~/.notmuch-config. Without it every scoped
query matches nothing and this silently no-ops.

Install: copy to <database.path>/.notmuch/hooks/post-new, with mailrules.py
importable (same directory, or on PYTHONPATH).
"""

import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import mailrules
import qtmaildirconf

SCOPE = "tag:new"

# Tags this hook refuses to REMOVE, whatever a rule says.
#
# maildir.synchronize_flags is true, so `unread` is not just an index entry:
# removing it rewrites Maildir filenames and propagates to the server on the
# next mbsync. `inbox` is what keeps mail visible at all. Unattended, on every
# sync, either one silently reorganizes a mailbox in a way that is tedious to
# undo and reaches other clients before anyone notices.
#
# Adding these tags is untouched, and so is removing anything else: a rule may
# still strip `promo` or any tag of its own making.
#
# DELIBERATELY CONSERVATIVE, AND EXPECTED TO BE RELAXED. The rules in use today
# only add tags, so this forbids nothing anyone is doing. It exists because the
# hook runs unattended and a mistake here is expensive, not because removing
# `unread` is wrong in principle: an "archive anything in notify/* older than
# 90 days" rule is a reasonable thing to want and would need this list revised.
# When that day comes, the question to answer first is what confirms the rule
# before it runs, not whether the guard is annoying.
# NOT the same list as mailctl.py's PROTECTED_REMOVALS, and the two must not be
# merged. That one is `{inbox}` and is a GATE: a human can override it with
# --confirm-destructive. This one is `{unread, inbox}` and is a REFUSAL, because
# there is no human present to confirm anything when cron runs a sync.
PROTECTED_REMOVALS = frozenset({"unread", "inbox"})


def log(message):
    print(f"post-new: {message}", file=sys.stderr)


def _quote(value):
    """Escape a Message-ID for a double-quoted notmuch term."""
    return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'


def sent_only(query, folders):
    """The ids matching `query` whose files are ALL inside `folders`.

    None on failure, so the caller leaves `tag:new` in place rather than
    stripping on a half-read answer.

    **Every file has to be in a sent folder, not merely one of them.** notmuch
    deduplicates by Message-ID, so mail the user sends to another of their own
    accounts is ONE message with two files: the sender's Sent copy and the
    recipient's Inbox copy. The old query matched on a path and the tag applied
    to the message, so matching the Sent copy stripped `inbox` from the copy
    that had genuinely arrived, and the mail was missing from the account that
    received it (item 166).

    This cannot be expressed as a query, which is why it is a loop. Measured
    against a two-file message: `not path:"Inbox/**"` does NOT exclude it, and
    `count --output=files` on a path query reports every file of every matching
    message rather than the files that matched. Both read as if they worked and
    are wrong for the same reason, that a notmuch term is a predicate over a
    MESSAGE and the distinction being drawn here is between its FILES.
    """
    root = mail_root()
    if root is None:
        log("no mail root; leaving sent mail alone")
        return None

    prefixes = [root / folder for folder in folders]

    matched = search(query, "messages")
    if matched is None:
        return None

    # ponytail: one `notmuch search` per matched message. N is the sent mail
    # in tag:new, so an ordinary sync is a handful and a first-run reindex is
    # the whole corpus. Batch by parsing --format=json once if that ever
    # matters; it does not at this size, and the loop is the readable form.
    ids = []
    for message_id in matched:
        paths = search(f"id:{_quote(message_id)}", "files")
        if paths is None:
            return None
        if all(any(_within(Path(path), prefix) for prefix in prefixes)
               for path in paths):
            ids.append(message_id)
    return ids


def _within(path, prefix):
    """Whether `path` is inside `prefix`, compared as paths.

    Not `startswith`: `<root>/Sent-old/cur/1` starts with `<root>/Sent` and is
    a different folder. Same trap as the attachment-save path check in the
    application.
    """
    try:
        path.relative_to(prefix)
    except ValueError:
        return False
    return True


def mail_root():
    """The Maildir root, or None.

    `database.mail_root`, not `database.path`: notmuch can hold the Xapian
    index somewhere else entirely, and this user's does. Under that layout
    `database.path` is the INDEX directory and no message file is inside it.
    """
    result = subprocess.run(["notmuch", "config", "get", "database.mail_root"],
                            capture_output=True, text=True)
    if result.returncode != 0:
        return None
    value = result.stdout.strip()
    return Path(value) if value else None


def search(query, output):
    """`notmuch search --output=<output>` as a list, or None on failure.

    An id comes back bare here, without the `id:` prefix, because
    `--output=messages` prints `id:<value>` and the prefix is stripped.
    """
    result = subprocess.run(
        ["notmuch", "search", f"--output={output}", "--", query],
        capture_output=True, text=True)
    if result.returncode != 0:
        log(f"notmuch search failed: {result.stderr.strip()}")
        return None

    values = []
    for line in result.stdout.splitlines():
        line = line.strip()
        if not line:
            continue
        values.append(line[3:] if line.startswith("id:") else line)
    return values


def strip_inbox_from_sent(run):
    """Take `inbox` off mail the user SENT, and nothing else.

    `notmuch new` applies new.tags to every file it indexes, and it cannot
    tell an arrival from the copy qtmaildir files into a sent folder after a
    send. The result is sent mail carrying `inbox`, which puts it in an inbox
    view it never arrived in and in any hand-typed `tag:inbox` search.

    This is NOT a relaxation of PROTECTED_REMOVALS below, and the difference
    is the whole reason it can run unattended. That guard is about a RULE
    removing `inbox` from mail whose provenance the hook cannot judge. Here
    the provenance is the file's own path: a message inside a configured sent
    folder is one this system sent, and `inbox` was never true of it. Nothing
    the user could act on is being hidden.

    Only `inbox`. `unread` is untouched, because maildir.synchronize_flags is
    true and removing it rewrites Maildir filenames, which reaches the server
    on the next mbsync.

    Scoped to tag:new like every rule, so a sync never rewrites tags across
    the whole corpus. Mail already indexed keeps whatever it has.
    """
    folders = qtmaildirconf.sent_folders()
    if not folders:
        # No config, or no account keeping sent mail locally. Nothing to
        # protect, and this must NOT fall through to an empty query: notmuch
        # reads that as "match everything", which would strip `inbox` from
        # every newly indexed message on the system.
        return True

    query = f"{SCOPE} and ({qtmaildirconf.sent_query(folders)})"

    ids = sent_only(query, folders)
    if ids is None:
        return False

    for message_id in ids:
        if not run(["-inbox"], f"id:{_quote(message_id)}"):
            return False

    # A `notmuch tag` matching nothing SUCCEEDS, so the old log line said
    # "applied" whether it stripped four messages or none, and item 164 is
    # exactly the case where that distinction is the whole question: a draft
    # that kept `inbox` on a pass whose log claimed the carve-out had run.
    log(f"sent-folder carve-out applied over {len(folders)} folder(s), "
        f"{len(ids)} message(s)")
    return True


def protected_removals(rule):
    """The protected tags this rule would remove, if any."""
    return sorted(PROTECTED_REMOVALS.intersection(rule.remove))


def count(query):
    """How many messages a query matches, or `?` if the count itself failed.

    Diagnostic only: nothing branches on this. A failure here must not fail
    the sync, because the carve-out's own tag is what matters and it reports
    its own status separately.
    """
    result = subprocess.run(["notmuch", "count", "--", query],
                            capture_output=True, text=True)
    if result.returncode != 0:
        return "?"
    return result.stdout.strip() or "?"


def run_tag(arguments, query):
    result = subprocess.run(["notmuch", "tag"] + arguments + ["--", query],
                            capture_output=True, text=True)
    if result.returncode != 0:
        log(f"notmuch tag failed: {result.stderr.strip()}")
        return False
    return True


def main():
    store = mailrules.load()

    # A file that will not load must NOT reach the consumer below. If the
    # marker were cleared while the rules did not run, that mail could never
    # be tagged by these rules again: the failure is silent, permanent, and
    # invisible until someone notices a gap months later. Leaving tag:new in
    # place makes the next successful run catch up instead.
    if store.failed:
        for warning in store.warnings:
            log(warning)
        log("rules did not load; leaving tag:new in place")
        return 1

    if store.missing:
        log("no rules file; nothing to do")
        return 0

    # A dropped rule is not fatal, but it must be visible: this goes to the
    # sync log, which is where someone looks when a tag stops appearing.
    for warning in store.warnings:
        log(warning)

    applied = 0
    for rule in mailrules.ordered(store.rules):
        # Skip the rule, do not abort the run. A single over-reaching rule
        # must not cost the tagging every other rule would have done, and
        # aborting here would also leave tag:new set forever: the rule would
        # be refused again on every subsequent sync and the marker would never
        # be consumed.
        refused = protected_removals(rule)
        if refused:
            log(f"rule '{rule.id}' would remove {', '.join(refused)}; "
                f"skipped, this hook does not remove those unattended")
            continue

        query = mailrules.scoped_query(rule, SCOPE)
        if not run_tag(mailrules.tag_arguments(rule), query):
            log(f"rule '{rule.id}' failed; leaving tag:new in place")
            return 1
        applied += 1

    # AFTER the rules and BEFORE the marker is consumed. After, so a rule can
    # still see its own sent mail with `inbox` on it and match the way it
    # always did; before, because the marker is what scopes this to newly
    # indexed mail and consuming it first would leave nothing to match.
    if not strip_inbox_from_sent(run_tag):
        log("sent-folder carve-out failed; leaving tag:new in place")
        return 1

    # Only after every rule succeeded. A failure part way through leaves the
    # marker set, so re-running the hook is safe and finishes the work.
    if not run_tag(["-new"], SCOPE):
        return 1

    log(f"applied {applied} rule(s)")
    return 0


if __name__ == "__main__":
    sys.exit(main())