diff --git a/apps/base/mail/kustomization.yaml b/apps/base/mail/kustomization.yaml index bdeda69..21224c2 100644 --- a/apps/base/mail/kustomization.yaml +++ b/apps/base/mail/kustomization.yaml @@ -14,8 +14,17 @@ resources: - stalwart-configmap.yaml - stalwart-pdb.yaml - stalwart-service.yaml + - stalwart-sieve-reconcile-cronjob.yaml - stalwart-statefulset.yaml - valkey.yaml - vaultauth.yaml - vaultstaticsecret.yaml - vmpodscrape.yaml + +configMapGenerator: + - name: stalwart-sieve-reconcile + namespace: mail + options: + disableNameSuffixHash: true + files: + - sieve-reconcile.py=resources/sieve-reconcile.py diff --git a/apps/base/mail/resources/sieve-reconcile.py b/apps/base/mail/resources/sieve-reconcile.py new file mode 100644 index 0000000..ce76fcb --- /dev/null +++ b/apps/base/mail/resources/sieve-reconcile.py @@ -0,0 +1,99 @@ +import base64 +import json +import os +import re +import sys +import urllib.parse +import urllib.request + +URL = os.environ["STALWART_URL"].rstrip("/") +AUTH = "Basic " + base64.b64encode( + f"{os.environ['STALWART_USER']}:{os.environ['STALWART_PASSWORD']}".encode() +).decode() +USING = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:sieve", "urn:stalwart:jmap"] +HEADER = 'require ["include"];\ninclude :global "spam-to-junk";\n' +HAS_INCLUDE = re.compile(r'\binclude\b[^;]*"spam-to-junk"') +REQUIRE = re.compile(r"\s*require\b") + + +def http(path, data=None, ctype="application/json"): + req = urllib.request.Request(URL + path, data=data, headers={"Authorization": AUTH}) + if data is not None: + req.add_header("Content-Type", ctype) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read() + + +def jmap(*calls): + body = json.dumps({"using": USING, "methodCalls": [[m, a, str(i)] for i, (m, a) in enumerate(calls)]}) + responses = json.loads(http("/jmap", body.encode()))["methodResponses"] + for name, args, _ in responses: + if name == "error": + raise RuntimeError(args) + return [args for _, args, _ in responses] + + +def with_include(script): + lines = script.splitlines(keepends=True) + i, in_require = 0, False + while i < len(lines): + line = lines[i].strip() + if in_require or REQUIRE.match(lines[i]): + in_require = not line.endswith(";") + elif line and not line.startswith("#"): + break + i += 1 + return HEADER + "".join(lines[:i]) + "".join(lines[i:]) + + +def users(): + ids, position = [], 0 + while True: + (page,) = jmap(("x:Account/query", {"position": position, "limit": 500})) + ids += page["ids"] + position += len(page["ids"]) + if not page["ids"] or position >= page.get("total", position): + break + for start in range(0, len(ids), 500): + (got,) = jmap(("x:Account/get", {"ids": ids[start:start + 500], "properties": ["@type", "name"]})) + yield from (a for a in got["list"] if a["@type"] == "User") + + +def reconcile(account): + acct = account["id"] + (got,) = jmap(("SieveScript/get", {"accountId": acct})) + scripts = got["list"] + target = next((s for s in scripts if s["isActive"]), None) or next( + (s for s in scripts if s["name"] == "default"), None + ) + content = "" + if target: + content = http(f"/jmap/download/{acct}/{target['blobId']}/script").decode() + if target["isActive"] and HAS_INCLUDE.search(content): + return + if not HAS_INCLUDE.search(content): + content = with_include(content) + blob = json.loads(http(f"/jmap/upload/{acct}/", content.encode(), "application/sieve"))["blobId"] + if target: + args = {"accountId": acct, "update": {target["id"]: {"blobId": blob}}, + "onSuccessActivateScript": target["id"]} + action = f"added spam-to-junk include to {target['name']!r}" + else: + args = {"accountId": acct, "create": {"d": {"name": "default", "blobId": blob}}, + "onSuccessActivateScript": "#d"} + action = "created and activated 'default'" + (res,) = jmap(("SieveScript/set", args)) + failed = res.get("notCreated") or res.get("notUpdated") + if failed: + raise RuntimeError(failed) + print(f"{account['name']}: {action}", flush=True) + + +errors = 0 +for account in users(): + try: + reconcile(account) + except Exception as err: + errors += 1 + print(f"{account['name']}: {err}", file=sys.stderr, flush=True) +sys.exit(1 if errors else 0) diff --git a/apps/base/mail/stalwart-sieve-reconcile-cronjob.yaml b/apps/base/mail/stalwart-sieve-reconcile-cronjob.yaml new file mode 100644 index 0000000..ac2f154 --- /dev/null +++ b/apps/base/mail/stalwart-sieve-reconcile-cronjob.yaml @@ -0,0 +1,70 @@ +--- +# Keeps an active personal Sieve script including the global spam-to-junk +# script on every individual account, so rspamd-tagged mail lands in Junk. +apiVersion: batch/v1 +kind: CronJob +metadata: + name: stalwart-sieve-reconcile + namespace: mail +spec: + schedule: "*/15 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 600 + backoffLimit: 1 + template: + metadata: + labels: + app.kubernetes.io/name: stalwart-sieve-reconcile + spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: reconcile + image: docker.io/library/python:3.13-alpine + command: + - python + - /scripts/sieve-reconcile.py + env: + - name: STALWART_URL + value: http://stalwart.mail.svc:8080 + - name: STALWART_USER + valueFrom: + secretKeyRef: + name: stalwart-admin + key: username + - name: STALWART_PASSWORD + valueFrom: + secretKeyRef: + name: stalwart-admin + key: password + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi + volumeMounts: + - name: scripts + mountPath: /scripts + readOnly: true + volumes: + - name: scripts + configMap: + name: stalwart-sieve-reconcile