aboutsummaryrefslogtreecommitdiffstats
path: root/mailrules.py
blob: c5293481e4a5c9e88bbdf6f64fbc64c412eab781 (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
#!/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.
"""Shared notmuch tagging-rule store.

The rules live in ~/.config/mailrules/rules.json and are read by both this
tool and qtmaildir, so the format belongs to neither: a field one tool does
not understand is preserved verbatim across a save by the other.

A rule carries NO scope. The post-new hook supplies `tag:new`, a dry run
supplies nothing and counts against the whole corpus. This is what lets one
rule serve arrivals, a dry run, and (later) a backfill over history.

Stdlib only, deliberately: this module is imported by a notmuch hook that
runs on every sync, and mailctl has no dependencies to inherit.
"""

import json
import os
from dataclasses import dataclass, field
from pathlib import Path

FORMAT_VERSION = 1
DEFAULT_STAGE = 50

# Fields this version understands. Anything else in a rule object is kept in
# `unknown` and written back untouched, which is what makes the file neutral
# rather than this tool's file that another program may read.
KNOWN_KEYS = {"id", "stage", "enabled", "add", "remove", "query", "note"}


@dataclass
class Rule:
    id: str
    query: str
    add: list = field(default_factory=list)
    remove: list = field(default_factory=list)
    stage: int = DEFAULT_STAGE
    enabled: bool = True
    note: str = ""
    unknown: dict = field(default_factory=dict)


@dataclass
class Store:
    rules: list = field(default_factory=list)
    warnings: list = field(default_factory=list)
    unknown: dict = field(default_factory=dict)


def default_path():
    """$XDG_CONFIG_HOME/mailrules/rules.json, or ~/.config/... as fallback.

    No hardcoded home directory: both tools must resolve the same path, and
    a user with XDG_CONFIG_HOME set expects it honoured.
    """
    base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
    return Path(base) / "mailrules" / "rules.json"


def load(path=None):
    """Read the store. Never raises for a bad file: problems land in
    Store.warnings and the offending rule is dropped, so one malformed rule
    cannot stop the other nineteen from running."""
    path = Path(path) if path else default_path()
    store = Store()

    raw = json.loads(path.read_text())

    for obj in raw.get("rules", []):
        store.rules.append(Rule(
            id=obj["id"],
            query=obj["query"],
            add=list(obj.get("add", [])),
            remove=list(obj.get("remove", [])),
            stage=int(obj.get("stage", DEFAULT_STAGE)),
            enabled=bool(obj.get("enabled", True)),
            note=obj.get("note", ""),
            unknown={k: v for k, v in obj.items() if k not in KNOWN_KEYS},
        ))

    return store