Dev compose gains rspamd + redis-rspamd, matching production. The dev API and worker run on the host rather than in the compose network, so the ports are published on loopback and RSPAMD_URL points at 127.0.0.1 — documented in .env.example. Contacts now cap a verdict at "banner": mail from someone in the user's address book is never moved to Junk. Capped rather than exempted, because the way this fails in practice is a contact's account being compromised and spamming its own address book — the warning still has value even when the move would be wrong. An explicit deny rule still wins; that is the user overruling us deliberately. The lookup reads the contacts plugin's table directly and forgives its absence: no contacts plugin means no contacts to check, not an error. Addresses are stored as the user typed them, so both sides are lower-cased rather than relying on the jsonb containment index, and secondary addresses in the emails array count as well as the primary. It runs only when the verdict would otherwise be a move, so the common path pays nothing. Also fixes a test that asserted stats degrade "without an engine" by relying on no engine being present — true until this commit. It now simulates the absence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
147 lines
5.6 KiB
Python
147 lines
5.6 KiB
Python
"""Rspamd HTTP client (SPEC §C.2).
|
|
|
|
The engine is a container, not a library — this module is the whole of our
|
|
coupling to it. Two rules shape everything here:
|
|
|
|
* **Fail open.** Rspamd being down, slow or wrong must never delay or lose mail
|
|
(§C.4). Every entry point returns ``None`` instead of raising, and the caller
|
|
treats ``None`` as "deliver normally, unclassified".
|
|
* **Not through the SDK's HTTP.** ``sdk.http`` applies the §10.6 SSRF guard,
|
|
which blocks private addresses — correct for user-supplied CalDAV URLs, wrong
|
|
for an operator-configured service on the compose network. The URL here comes
|
|
from the environment, never from a user, so the guard protects nothing and
|
|
would only forbid the one address that works.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger("plg.antispam")
|
|
|
|
# The compose service name; operators override for an external engine.
|
|
# Rspamd splits its HTTP over two workers and they are NOT interchangeable:
|
|
# :11333 scan worker -> /checkv2, unauthenticated
|
|
# :11334 controller -> /learnspam, /learnham, /stat, password-gated
|
|
# Pointing learning at the scan port silently fails, so both are named here.
|
|
RSPAMD_URL = os.environ.get("RSPAMD_URL", "http://rspamd:11333")
|
|
RSPAMD_CONTROLLER_URL = os.environ.get("RSPAMD_CONTROLLER_URL", "http://rspamd:11334")
|
|
# Internal credential (generated into .env by `webmail.sh init`). Humans never
|
|
# present this: the admin panel authenticates them as the webmail administrator
|
|
# and talks to the controller on their behalf.
|
|
CONTROLLER_PASSWORD = os.environ.get("RSPAMD_CONTROLLER_PASSWORD", "")
|
|
# Generous enough for a large message, short enough that a hung engine cannot
|
|
# stall the sync worker: classification is best-effort, delivery is not.
|
|
TIMEOUT = float(os.environ.get("RSPAMD_TIMEOUT", "10"))
|
|
|
|
# Settings profiles defined in docker/rspamd/local.d/settings.conf. The privacy
|
|
# default (§C.2) is enforced in that file, not here: "no network" is the profile
|
|
# that omits the RBL/SURBL/fuzzy modules entirely, so a bug in this client
|
|
# cannot silently start leaking URLs and hashes to third parties.
|
|
SETTINGS_OFFLINE = "webmail_offline"
|
|
SETTINGS_NETWORK = "webmail_network"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Verdict:
|
|
"""What the engine thought. ``symbols`` is kept for explainability (§C.3)."""
|
|
|
|
score: float
|
|
required_score: float
|
|
action: str
|
|
symbols: list[str] = field(default_factory=list)
|
|
|
|
|
|
def _parse(data: dict[str, Any]) -> Verdict | None:
|
|
try:
|
|
score = float(data["score"])
|
|
except (KeyError, TypeError, ValueError):
|
|
return None
|
|
raw_symbols = data.get("symbols")
|
|
symbols = sorted(raw_symbols) if isinstance(raw_symbols, dict) else []
|
|
try:
|
|
required = float(data.get("required_score", 0.0))
|
|
except (TypeError, ValueError):
|
|
required = 0.0
|
|
return Verdict(
|
|
score=score,
|
|
required_score=required,
|
|
action=str(data.get("action", "no action")),
|
|
symbols=symbols,
|
|
)
|
|
|
|
|
|
def _headers(user_key: str, network_checks: bool) -> dict[str, str]:
|
|
# Deliver-To is what Rspamd keys per-user settings and per-user Bayes on, so
|
|
# one user's training never moves another user's scores (§C.5, acceptance 4).
|
|
return {
|
|
"Deliver-To": user_key,
|
|
"Settings-ID": SETTINGS_NETWORK if network_checks else SETTINGS_OFFLINE,
|
|
}
|
|
|
|
|
|
async def check(
|
|
raw: bytes, *, user_key: str, network_checks: bool = False
|
|
) -> Verdict | None:
|
|
"""Score one raw RFC822 message. ``None`` means "could not classify"."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
|
resp = await client.post(
|
|
f"{RSPAMD_URL}/checkv2",
|
|
content=raw,
|
|
headers=_headers(user_key, network_checks),
|
|
)
|
|
resp.raise_for_status()
|
|
return _parse(resp.json())
|
|
except Exception as exc: # noqa: BLE001 - fail open, whatever went wrong
|
|
logger.warning("rspamd check unavailable (%s); delivering unclassified", exc)
|
|
return None
|
|
|
|
|
|
def _controller_headers(user_key: str) -> dict[str, str]:
|
|
headers = {"Deliver-To": user_key}
|
|
if CONTROLLER_PASSWORD:
|
|
headers["Password"] = CONTROLLER_PASSWORD
|
|
return headers
|
|
|
|
|
|
async def learn(raw: bytes, *, user_key: str, spam: bool) -> bool:
|
|
"""Teach this user's Bayes. Returns False if the engine was unreachable.
|
|
|
|
Goes to the CONTROLLER with the password, unlike :func:`check`. Used by the
|
|
C2 learning loop.
|
|
"""
|
|
endpoint = "/learnspam" if spam else "/learnham"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
|
resp = await client.post(
|
|
f"{RSPAMD_CONTROLLER_URL}{endpoint}",
|
|
content=raw,
|
|
headers=_controller_headers(user_key),
|
|
)
|
|
resp.raise_for_status()
|
|
return True
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("rspamd learn failed (%s)", exc)
|
|
return False
|
|
|
|
|
|
async def stats(*, user_key: str) -> dict[str, Any] | None:
|
|
"""Controller ``/stat`` — feeds the §C.6 stats page. None when unreachable."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
|
resp = await client.get(
|
|
f"{RSPAMD_CONTROLLER_URL}/stat", headers=_controller_headers(user_key)
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data if isinstance(data, dict) else None
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("rspamd stat unavailable (%s)", exc)
|
|
return None
|