From 1b4f6ed34e4bcad65e74b3944cde5730973ea65d Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 29 Sep 2026 09:48:12 +0200 Subject: [PATCH] redesign filter page: rule test, match stats, warnings, toggle - Rule tester (POST /api/filters/test): evaluates an unsaved rule read-only against the newest matching mails in the source folder, using an IMAP SEARCH on the first text condition to narrow candidates. Shows matches, near misses with the failing condition, and whether any candidate had an attachment at all (would have caught the Klarmobil and it-moench misconfigurations immediately). - Per-rule match_count / last_match_at (new migration), incremented by the scheduler on every match and shown in the list. - PATCH /api/filters/{id}/enabled toggles a rule without resetting its processed state (a full PUT re-evaluates every mail for that rule). - New filter list: cards with enable switch, priority, condition and action chips, match stats, and warnings (display name in from/to, "am (exakt)" date, duplicates, empty values, invalid regex, move without folder). Quick filters for active/inactive/warnings/never matched; copy-as-template; remembered account selection. - Edit dialog: wider layout, live warnings, integrated test panel, sensible defaults for new rules (from + date after Jan 1 + move). - Light global refresh: nav with active item, page background, cards. Co-Authored-By: Claude Opus 5.5 --- .../7d1e4a2b9c30_add_rule_match_stats.py | 31 + app/models/db_models.py | 2 + app/routers/filters.py | 38 +- app/schemas/schemas.py | 13 + app/services/rule_tester.py | 122 +++ app/services/scheduler.py | 2 + app/static/style.css | 41 +- app/templates/base.html | 12 +- app/templates/filters.html | 965 +++++++++++------- 9 files changed, 849 insertions(+), 377 deletions(-) create mode 100644 alembic/versions/7d1e4a2b9c30_add_rule_match_stats.py create mode 100644 app/services/rule_tester.py diff --git a/alembic/versions/7d1e4a2b9c30_add_rule_match_stats.py b/alembic/versions/7d1e4a2b9c30_add_rule_match_stats.py new file mode 100644 index 0000000..eea26e2 --- /dev/null +++ b/alembic/versions/7d1e4a2b9c30_add_rule_match_stats.py @@ -0,0 +1,31 @@ +"""add match statistics to filter_rules + +Revision ID: 7d1e4a2b9c30 +Revises: 3bccad0c6646 +Create Date: 2026-09-29 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "7d1e4a2b9c30" +down_revision: Union[str, Sequence[str], None] = "3bccad0c6646" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Trefferzähler und Zeitpunkt des letzten Treffers pro Regel, für die + Anzeige in der Filterliste.""" + with op.batch_alter_table("filter_rules", schema=None) as batch_op: + batch_op.add_column(sa.Column("match_count", sa.Integer(), nullable=False, server_default="0")) + batch_op.add_column(sa.Column("last_match_at", sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("filter_rules", schema=None) as batch_op: + batch_op.drop_column("last_match_at") + batch_op.drop_column("match_count") diff --git a/app/models/db_models.py b/app/models/db_models.py index 67f6d6e..7653c7d 100644 --- a/app/models/db_models.py +++ b/app/models/db_models.py @@ -78,6 +78,8 @@ class FilterRule(Base): enabled: Mapped[bool] = mapped_column(Boolean, default=True) stop_processing: Mapped[bool] = mapped_column(Boolean, default=False) source_folder: Mapped[str] = mapped_column(String(255), default="INBOX") + match_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + last_match_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), onupdate=func.now() diff --git a/app/routers/filters.py b/app/routers/filters.py index c99e8cb..17ffddd 100644 --- a/app/routers/filters.py +++ b/app/routers/filters.py @@ -14,7 +14,14 @@ from app.models.db_models import ( LogLevel, ProcessedMail, ) -from app.schemas.schemas import FilterRuleCreate, FilterRuleResponse, FilterRuleUpdate +from app.schemas.schemas import ( + FilterRuleCreate, + FilterRuleEnabled, + FilterRuleResponse, + FilterRuleTest, + FilterRuleUpdate, +) +from app.services.rule_tester import test_rule logger = logging.getLogger(__name__) @@ -33,6 +40,35 @@ def _reset_processed_for_rule(db: Session, rule_id: int) -> int: router = APIRouter(prefix="/api/filters", tags=["filters"]) +@router.post("/test") +def test_filter(data: FilterRuleTest, db: Session = Depends(get_db)): + """Prüft eine Regel read-only gegen die neuesten passenden Mails im + Quellordner, ohne sie zu speichern oder Mails zu verändern.""" + account = db.get(Account, data.account_id) + if not account: + raise HTTPException(404, "Konto nicht gefunden") + if not data.conditions: + raise HTTPException(400, "Keine Bedingungen angegeben") + try: + return test_rule(account, data.source_folder, data.conditions, data.limit) + except Exception as e: + logger.exception("Regeltest fehlgeschlagen") + raise HTTPException(500, f"Test fehlgeschlagen: {e}") + + +@router.patch("/{rule_id}/enabled", response_model=FilterRuleResponse) +def set_filter_enabled(rule_id: int, data: FilterRuleEnabled, db: Session = Depends(get_db)): + """Regel ein-/ausschalten, ohne den Verarbeitungsstatus zurückzusetzen + (ein normales Update würde alle Mails für diese Regel neu prüfen lassen).""" + rule = db.get(FilterRule, rule_id) + if not rule: + raise HTTPException(404, "Filterregel nicht gefunden") + rule.enabled = data.enabled + db.commit() + db.refresh(rule) + return rule + + @router.post("/reset-all-processed") def reset_all_processed(db: Session = Depends(get_db)): """Setzt den 'verarbeitet'-Status ALLER Mails zurück — alle Regeln bewerten diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 1f501b3..4d2ad18 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -67,6 +67,8 @@ class FilterRuleResponse(BaseModel): enabled: bool stop_processing: bool source_folder: str + match_count: int = 0 + last_match_at: datetime | None = None conditions: list[FilterConditionResponse] actions: list[FilterActionResponse] created_at: datetime @@ -75,6 +77,17 @@ class FilterRuleResponse(BaseModel): model_config = {"from_attributes": True} +class FilterRuleEnabled(BaseModel): + enabled: bool + + +class FilterRuleTest(BaseModel): + account_id: int + source_folder: str = "INBOX" + conditions: list[FilterConditionCreate] + limit: int = 40 + + # --- Account --- diff --git a/app/services/rule_tester.py b/app/services/rule_tester.py new file mode 100644 index 0000000..e6ec160 --- /dev/null +++ b/app/services/rule_tester.py @@ -0,0 +1,122 @@ +"""Testet eine (noch nicht gespeicherte) Regel read-only gegen echte Mails im +Quellordner. Liefert Treffer und Beinahe-Treffer samt der Bedingung, an der +es scheitert — damit Fehlkonfigurationen (Anzeigename im Absender, fehlender +Anhang, falscher Betreff) schon beim Anlegen auffallen.""" +import logging +import re +from datetime import datetime, timedelta + +from app.models.db_models import Account, ConditionField, MatchType +from app.services.encryption import decrypt +from app.services.filter_engine import _parse_date_value, evaluate_conditions +from app.services.imap_client import IMAPClient + +logger = logging.getLogger(__name__) + +_SEARCH_KEYS = { + ConditionField.FROM: "FROM", + ConditionField.TO: "TO", + ConditionField.SUBJECT: "SUBJECT", +} + + +def _imap_date(d: datetime) -> str: + months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + return f"{d.day:02d}-{months[d.month - 1]}-{d.year}" + + +def _search_term(value: str) -> str | None: + """Suchbegriff für IMAP SEARCH: bei 'Name ' nur die Adresse. + Nicht-ASCII-Begriffe werden serverseitig nicht zuverlässig unterstützt.""" + m = re.search(r"<([^>]+)>", value) + term = " ".join((m.group(1) if m else value).split()) + if not term or not term.isascii(): + return None + return term.replace("\\", "\\\\").replace('"', '\\"') + + +def _build_search(conditions) -> list[str]: + """Grenzt die Kandidaten serverseitig ein, damit nicht 150k Mails geladen + werden müssen. Die eigentliche Prüfung macht danach evaluate_conditions.""" + criteria: list[str] = [] + for c in conditions: + if c.negate: + continue + if c.field in _SEARCH_KEYS and c.match_type in (MatchType.CONTAINS, MatchType.EXACT): + term = _search_term(c.value) + if term: + criteria += [_SEARCH_KEYS[c.field], f'"{term}"'] + break + since = None + for c in conditions: + if c.field == ConditionField.DATE and not c.negate and c.match_type in (MatchType.AFTER, MatchType.ON_DATE): + since = _parse_date_value(c.value) + break + if not criteria and since is None: + # Ohne Anhaltspunkt: nur die letzten 60 Tage + since = datetime.utcnow() - timedelta(days=60) + if since is not None: + criteria += ["SINCE", _imap_date(since)] + return criteria + + +def test_rule(account: Account, source_folder: str, conditions, limit: int = 40) -> dict: + limit = max(1, min(limit, 100)) + criteria = _build_search(conditions) + client = IMAPClient( + host=account.imap_host, + port=account.imap_port, + username=account.username, + password=decrypt(account.password), + use_ssl=account.use_ssl, + ) + with client: + if not client.select_folder(source_folder, readonly=True): + raise ValueError(f"Ordner '{source_folder}' konnte nicht geöffnet werden") + status, data = client.conn.uid("SEARCH", None, *criteria) + uids = data[0].split() if status == "OK" and data and data[0] else [] + candidates = len(uids) + # Neueste zuerst + uids = sorted(uids, key=int, reverse=True)[:limit] + + matches, misses = [], [] + failed_on: dict[str, int] = {} + attachments_seen = 0 + for raw_uid in uids: + uid = raw_uid.decode() if isinstance(raw_uid, bytes) else str(raw_uid) + try: + mail = client.fetch_mail(uid) + except Exception as e: + misses.append({"uid": uid, "from": "", "subject": "", "date": None, + "has_attachment": False, "reason": f"Abruf fehlgeschlagen: {e}"}) + continue + if not mail: + continue + if mail.has_attachment: + attachments_seen += 1 + ok, details = evaluate_conditions(mail, conditions) + entry = { + "uid": uid, + "from": mail.from_addr, + "subject": mail.subject, + "date": mail.date.isoformat() if mail.date else None, + "has_attachment": mail.has_attachment, + } + if ok: + matches.append(entry) + else: + reason = details[-1] if details else "" + entry["reason"] = reason + misses.append(entry) + field = reason.split("(", 1)[0].replace("NOT ", "").strip() or "?" + failed_on[field] = failed_on.get(field, 0) + 1 + + return { + "search": " ".join(criteria) or "ALL", + "candidates": candidates, + "checked": len(matches) + len(misses), + "matches": matches, + "misses": misses, + "failed_on": failed_on, + "attachments_seen": attachments_seen, + } diff --git a/app/services/scheduler.py b/app/services/scheduler.py index 70d52e3..b239ffa 100644 --- a/app/services/scheduler.py +++ b/app/services/scheduler.py @@ -201,6 +201,8 @@ def _poll_account_sync(account_id: int) -> None: if matched: total_matched += 1 + rule.match_count = (rule.match_count or 0) + 1 + rule.last_match_at = datetime.utcnow() # Aktionen ausführen for action in rule.actions: success = execute_action( diff --git a/app/static/style.css b/app/static/style.css index b8af150..751eca6 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,8 +1,41 @@ -/* Kleine Anpassungen über PicoCSS hinaus */ +/* Anpassungen über PicoCSS hinaus */ -nav .container-fluid { - padding-top: 0.5rem; - padding-bottom: 0.5rem; +:root { + --app-bg: #f6f7f9; + --app-border: #e3e6ea; +} + +body { + background: var(--app-bg); +} + +nav.container-fluid { + background: #fff; + border-bottom: 1px solid var(--app-border); + padding-top: 0.25rem; + padding-bottom: 0.25rem; + margin-bottom: 1.5rem; +} + +nav .brand { + font-weight: 700; + letter-spacing: -0.01em; +} + +nav .brand span { + color: var(--pico-primary); +} + +nav a.active { + color: var(--pico-primary); + font-weight: 600; + box-shadow: inset 0 -2px 0 var(--pico-primary); + border-radius: 0; +} + +main.container article { + border: 1px solid var(--app-border); + box-shadow: none; } footer { diff --git a/app/templates/base.html b/app/templates/base.html index 5eb5765..873b2b1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -10,7 +10,7 @@