Reconcile a spam-to-junk Sieve include onto every Stalwart account
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user