aboutsummaryrefslogtreecommitdiffstats
path: root/assets/hooks/test_mailrules.py
blob: b1f31c93e08ac0c9db66aac92413181b0c54aad3 (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
#!/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.
"""Self-checks for mailrules.py, the shared tagging-rule store.

The risk in this file is the format, not the notmuch calls: a rule that
silently loses a field on save, or one that sorts into the wrong stage,
mis-tags real mail on the next sync and does it quietly.

Run: ./test_mailrules.py
"""

import json
import tempfile
from pathlib import Path

import mailrules


def write_rules(tmp, payload):
    path = Path(tmp) / "rules.json"
    path.write_text(json.dumps(payload))
    return path


def test_loads_a_rule():
    with tempfile.TemporaryDirectory() as tmp:
        path = write_rules(tmp, {
            "version": 1,
            "rules": [
                {
                    "id": "notify-forge",
                    "stage": 50,
                    "enabled": True,
                    "add": ["notify/forge"],
                    "remove": [],
                    "query": "from:notifications@example.com",
                    "note": "All repositories, not one project.",
                }
            ],
        })
        store = mailrules.load(path)
        assert store.warnings == [], store.warnings
        assert len(store.rules) == 1
        rule = store.rules[0]
        assert rule.id == "notify-forge"
        assert rule.stage == 50
        assert rule.enabled is True
        assert rule.add == ["notify/forge"]
        assert rule.remove == []
        assert rule.query == "from:notifications@example.com"
        assert rule.note == "All repositories, not one project."


def test_defaults_are_applied():
    """stage, enabled, remove and note are all optional in the file."""
    with tempfile.TemporaryDirectory() as tmp:
        path = write_rules(tmp, {
            "version": 1,
            "rules": [{"id": "minimal", "add": ["x"],
                       "query": "from:someone@example.com"}],
        })
        store = mailrules.load(path)
        assert store.warnings == [], store.warnings
        rule = store.rules[0]
        assert rule.stage == 50
        assert rule.enabled is True
        assert rule.remove == []
        assert rule.note == ""


def test_a_bad_rule_is_dropped_and_the_rest_survive():
    """One malformed rule must not stop the others. The hook runs every ten
    minutes on real mail; losing all tagging because of one typo is worse
    than losing one rule."""
    with tempfile.TemporaryDirectory() as tmp:
        path = write_rules(tmp, {
            "version": 1,
            "rules": [
                {"id": "good", "add": ["x"], "query": "from:a@example.com"},
                {"id": "no-query", "add": ["y"]},
                {"id": "no-tags", "query": "from:b@example.com"},
                {"id": "bad id!", "add": ["z"], "query": "from:c@example.com"},
                {"add": ["w"], "query": "from:d@example.com"},
            ],
        })
        store = mailrules.load(path)
        assert [r.id for r in store.rules] == ["good"]
        assert len(store.warnings) == 4, store.warnings
        joined = " ".join(store.warnings)
        assert "no-query" in joined
        assert "no-tags" in joined
        assert "bad id!" in joined


def test_duplicate_ids_keep_the_first():
    with tempfile.TemporaryDirectory() as tmp:
        path = write_rules(tmp, {
            "version": 1,
            "rules": [
                {"id": "dup", "add": ["first"], "query": "from:a@example.com"},
                {"id": "dup", "add": ["second"], "query": "from:b@example.com"},
            ],
        })
        store = mailrules.load(path)
        assert len(store.rules) == 1
        assert store.rules[0].add == ["first"]
        assert any("dup" in w for w in store.warnings)


def test_a_missing_file_is_empty_not_an_error():
    """qtmaildir must open on a machine that has never written this file."""
    with tempfile.TemporaryDirectory() as tmp:
        store = mailrules.load(Path(tmp) / "absent.json")
        assert store.rules == []
        assert store.warnings == []
        assert store.missing is True


def test_unparseable_json_warns_and_yields_no_rules():
    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / "rules.json"
        path.write_text("{not json")
        store = mailrules.load(path)
        assert store.rules == []
        assert len(store.warnings) == 1
        assert store.failed is True


def test_a_newer_format_version_is_refused():
    """Guessing at semantics a later version defined is how a rule silently
    changes meaning. Refuse instead."""
    with tempfile.TemporaryDirectory() as tmp:
        path = write_rules(tmp, {
            "version": 2,
            "rules": [{"id": "x", "add": ["a"], "query": "from:a@example.com"}],
        })
        store = mailrules.load(path)
        assert store.rules == []
        assert store.failed is True
        assert any("version" in w for w in store.warnings)


def test_ordered_sorts_by_stage_then_file_position():
    """Account tags must run before topic rules. Ties keep file order, so
    the file still reads as a sequence."""
    with tempfile.TemporaryDirectory() as tmp:
        path = write_rules(tmp, {
            "version": 1,
            "rules": [
                {"id": "topic-b", "stage": 50, "add": ["b"],
                 "query": "from:b@example.com"},
                {"id": "account", "stage": 10, "add": ["acct"],
                 "query": "path:\"work/**\""},
                {"id": "topic-a", "stage": 50, "add": ["a"],
                 "query": "from:a@example.com"},
            ],
        })
        store = mailrules.load(path)
        assert [r.id for r in mailrules.ordered(store.rules)] == [
            "account", "topic-b", "topic-a"]


def test_ordered_skips_disabled_rules():
    with tempfile.TemporaryDirectory() as tmp:
        path = write_rules(tmp, {
            "version": 1,
            "rules": [
                {"id": "on", "add": ["a"], "query": "from:a@example.com"},
                {"id": "off", "add": ["b"], "query": "from:b@example.com",
                 "enabled": False},
            ],
        })
        store = mailrules.load(path)
        assert [r.id for r in mailrules.ordered(store.rules)] == ["on"]
        # The disabled rule is still LOADED, so a UI can show and re-enable it.
        assert [r.id for r in store.rules] == ["on", "off"]


def test_save_round_trips_unknown_fields():
    """The neutrality guarantee. If this tool strips a field qtmaildir
    added, the file is this tool's file that qtmaildir may read."""
    with tempfile.TemporaryDirectory() as tmp:
        path = write_rules(tmp, {
            "version": 1,
            "future_top_level": {"set_by": "another tool"},
            "rules": [{
                "id": "keeper",
                "add": ["x"],
                "query": "from:a@example.com",
                "future_field": [1, 2, 3],
            }],
        })
        store = mailrules.load(path)
        assert store.rules[0].unknown == {"future_field": [1, 2, 3]}

        mailrules.save(store, path)

        raw = json.loads(path.read_text())
        assert raw["future_top_level"] == {"set_by": "another tool"}
        assert raw["rules"][0]["future_field"] == [1, 2, 3]
        assert raw["rules"][0]["id"] == "keeper"
        assert raw["version"] == 1


def test_save_is_atomic():
    """A reader must never see a half-written file: the hook runs every ten
    minutes and a truncated read would be a failed sync."""
    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / "rules.json"
        store = mailrules.Store(rules=[
            mailrules.Rule(id="a", query="from:a@example.com", add=["x"])])
        mailrules.save(store, path)
        # The temp file the write went through must not be left behind.
        assert [p.name for p in Path(tmp).iterdir()] == ["rules.json"]
        assert json.loads(path.read_text())["rules"][0]["id"] == "a"


def test_save_creates_the_directory():
    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / "nested" / "rules.json"
        mailrules.save(mailrules.Store(), path)
        assert path.exists()
        assert json.loads(path.read_text()) == {"version": 1, "rules": []}


def test_scoped_query_parenthesises_the_rule():
    """Without the parentheses `tag:new and a or b` binds as
    `(tag:new and a) or b`, and the rule matches every message in the corpus
    satisfying b rather than only new arrivals. Several real rules are a
    disjunction of senders, so this is the difference between tagging four
    messages and tagging four thousand."""
    rule = mailrules.Rule(
        id="disjunction",
        query="from:a@example.com or from:b@example.com",
        add=["promo"])
    assert mailrules.scoped_query(rule, "tag:new") == (
        "tag:new and (from:a@example.com or from:b@example.com)")


def test_scoped_query_with_no_scope_is_the_bare_query():
    """A dry run counts against the whole corpus, which is what makes the
    same rule answer 'what would this tag on arrival' and 'what does this
    match in all my mail'."""
    rule = mailrules.Rule(id="x", query="from:a@example.com", add=["y"])
    assert mailrules.scoped_query(rule, None) == "from:a@example.com"
    assert mailrules.scoped_query(rule, "") == "from:a@example.com"


def test_tag_arguments():
    rule = mailrules.Rule(id="x", query="from:a@example.com",
                          add=["one", "two"], remove=["three"])
    assert mailrules.tag_arguments(rule) == ["+one", "+two", "-three"]


def run_all():
    for name, fn in sorted(globals().items()):
        if name.startswith("test_") and callable(fn):
            fn()
            print(f"ok  {name}")


if __name__ == "__main__":
    run_all()
    print("\nall passed")