aboutsummaryrefslogtreecommitdiffstats
path: root/test_mailctl.py
blob: c6a50b90447c224dd91ccdf28f56f9e65fc5ddda (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
#!/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 the bits of mailctl with real logic in them:

  - the senders address merge: notmuch dedupes on name-addr, so one address
    appears once per display name it ever used, and we merge on the address.
  - subject_terms: tokenizing/stopword-filtering subject lines, counting each
    term once per subject.
  - validate_accounts: the config gate. Every rejection case here is a typo
    that would otherwise silently produce a query matching nothing, or a
    draft written under the wrong identity.

Run: ./test_mailctl.py
"""

import io
import json
import os
import tempfile
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch

# mailctl validates its account config at import time, so a valid one has to
# exist before the import below. Point it at a throwaway maildir tree.
_tmp = tempfile.TemporaryDirectory()
FIXTURE_ROOT = Path(_tmp.name)
(FIXTURE_ROOT / "mail" / "acct-a" / "Drafts").mkdir(parents=True)
(FIXTURE_ROOT / "mail" / "acct-b").mkdir(parents=True)
FIXTURE_CONFIG = FIXTURE_ROOT / "accounts.json"
FIXTURE_CONFIG.write_text(json.dumps({"accounts": {
    "acct-a": {"maildir": "acct-a", "address": "a@example.org",
               "drafts": "Drafts"},
    "acct-b": {"maildir": "acct-b", "address": "b@example.org",
               "drafts": None},
}}))
os.environ["MAILCTL_CONFIG"] = str(FIXTURE_CONFIG)
os.environ["MAILCTL_MAIL_ROOT"] = str(FIXTURE_ROOT / "mail")

import mailctl  # noqa: E402  (must follow the env vars above)

MAIL = FIXTURE_ROOT / "mail"


def senders_json(raw, **overrides):
    """Run cmd_senders against a canned notmuch reply, return parsed JSON."""
    opts = {"query": "*", "account": None, "top": None, "json": True}
    opts.update(overrides)
    args = type("Args", (), opts)()
    buf = io.StringIO()
    with patch.object(mailctl, "run_notmuch", return_value=json.dumps(raw)), \
            redirect_stdout(buf):
        mailctl.cmd_senders(args)
    return json.loads(buf.getvalue())


def test_merges_on_address_keeping_longest_name():
    got = senders_json([
        {"name": "", "address": "a@x", "count": 112},
        {"name": "DPReview", "address": "a@x", "count": 353},
        {"name": "B", "address": "b@x", "count": 5},
    ])
    assert got == [
        {"address": "a@x", "name": "DPReview", "count": 465},
        {"address": "b@x", "name": "B", "count": 5},
    ], got


def test_top_truncates_after_sorting():
    got = senders_json([
        {"name": "small", "address": "s@x", "count": 1},
        {"name": "big", "address": "b@x", "count": 99},
    ], top=1)
    assert [a["address"] for a in got] == ["b@x"], got


def test_empty():
    assert senders_json([]) == []


def test_subject_terms_counts_each_term_once_per_subject():
    # "promo" three times in one subject must not outrank "sconto" in two
    got = mailctl.subject_terms(["Promo promo PROMO", "sconto", "Sconto!"])
    assert got["promo"] == 1, got
    assert got["sconto"] == 2, got


def test_subject_terms_drops_stopwords_digits_and_emoji():
    got = mailctl.subject_terms(["🔥 Le offerte di oggi for you 2024 ⏳"])
    assert set(got) == {"offerte"}, got


def test_subject_terms_keeps_accented_words():
    got = mailctl.subject_terms(["Località e novità"])
    assert set(got) == {"località", "novità"}, got


def test_subject_terms_empty():
    assert mailctl.subject_terms([]) == {}
    assert mailctl.subject_terms(["", "🔥"]) == {}


def rejects(accounts_value, expect_in_message):
    """Assert a config is refused, and that the message names the problem.

    Checking the message matters as much as the exit: these fire on the user's
    own typo, and a rejection that doesn't say which account and which field
    is barely better than a silent wrong answer.
    """
    buf = io.StringIO()
    try:
        with patch("sys.stderr", buf):
            mailctl.validate_accounts(accounts_value, MAIL)
    except SystemExit as e:
        assert e.code == 2, f"expected exit 2, got {e.code}"
        msg = buf.getvalue()
        assert expect_in_message in msg, f"want {expect_in_message!r} in:\n{msg}"
        return
    raise AssertionError(f"config was accepted but should not be: {accounts_value}")


def good(**overrides):
    spec = {"maildir": "acct-a", "address": "a@example.org", "drafts": "Drafts"}
    spec.update(overrides)
    return {"accounts": {"acct-a": spec}}


def test_valid_config_returns_both_maps():
    accounts, drafts = mailctl.validate_accounts(json.loads(
        FIXTURE_CONFIG.read_text()), MAIL)
    assert accounts == {"acct-a": ("acct-a", "a@example.org"),
                        "acct-b": ("acct-b", "b@example.org")}, accounts
    assert drafts == {"acct-a": "Drafts", "acct-b": None}, drafts


def test_rejects_misspelled_maildir():
    # the typo this whole gate exists for: scoping would match zero mail
    rejects(good(maildir="acct-A"), "does not exist")


def test_rejects_misspelled_drafts_dir():
    # would write a draft into a folder mbsync never syncs back
    rejects(good(drafts="Bozze"), "does not exist")


def test_rejects_absolute_and_traversing_maildir():
    rejects(good(maildir="/etc"), "plain subdirectory")
    rejects(good(maildir="../../etc"), "plain subdirectory")


def test_rejects_bad_address():
    rejects(good(address="a@example"), "not a valid email address")
    rejects(good(address="not-an-address"), "not a valid email address")


def test_rejects_unknown_field():
    # catches "addresss"/"maildirs" style typos instead of ignoring them
    rejects(good(adress="a@example.org"), "unknown field")


def test_rejects_missing_required_field():
    spec = good()
    del spec["accounts"]["acct-a"]["address"]
    rejects(spec, 'missing required field "address"')


def test_rejects_duplicate_maildir():
    rejects({"accounts": {
        "one": {"maildir": "acct-a", "address": "a@example.org", "drafts": None},
        "two": {"maildir": "acct-a", "address": "b@example.org", "drafts": None},
    }}, "is used by 2 accounts")


def test_rejects_structural_problems():
    rejects([], "must be a JSON object")
    rejects({}, 'missing top-level "accounts" key')
    rejects({"accounts": {}}, "non-empty")
    rejects({"accounts": {"acct-a": "acct-a"}}, "must be an object")
    rejects({"accounts": {"Acct A": good()["accounts"]["acct-a"]}},
            "invalid name")


def test_message_names_the_offending_account():
    buf = io.StringIO()
    try:
        with patch("sys.stderr", buf):
            mailctl.validate_accounts({"accounts": {
                "fine": {"maildir": "acct-b", "address": "b@example.org"},
                "broken": {"maildir": "nope", "address": "c@example.org"},
            }}, MAIL)
    except SystemExit:
        pass
    assert '"broken"' in buf.getvalue(), buf.getvalue()


def test_drafts_null_is_allowed():
    accounts, drafts = mailctl.validate_accounts(good(drafts=None), MAIL)
    assert drafts == {"acct-a": None}, drafts


def test_drafts_field_is_optional():
    spec = good()
    del spec["accounts"]["acct-a"]["drafts"]
    _, drafts = mailctl.validate_accounts(spec, MAIL)
    assert drafts == {"acct-a": None}, drafts


def test_load_accounts_rejects_bad_json():
    bad = FIXTURE_ROOT / "bad.json"
    bad.write_text("{not json")
    buf = io.StringIO()
    try:
        with patch("sys.stderr", buf):
            mailctl.load_accounts(bad, MAIL)
    except SystemExit as e:
        assert e.code == 2
        assert "not valid JSON" in buf.getvalue(), buf.getvalue()
        return
    raise AssertionError("bad JSON was accepted")


def test_load_accounts_missing_file_explains_how_to_create_it():
    buf = io.StringIO()
    try:
        with patch("sys.stderr", buf):
            mailctl.load_accounts(FIXTURE_ROOT / "absent.json", MAIL)
    except SystemExit as e:
        assert e.code == 2
        assert "no config at" in buf.getvalue(), buf.getvalue()
        return
    raise AssertionError("missing config was accepted")


if __name__ == "__main__":
    for name, fn in sorted(globals().items()):
        if name.startswith("test_") and callable(fn):
            fn()
    print("ok")