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 @@