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 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 09:48:12 +02:00
co-authored by Claude Opus 5.5
parent ce7caf5dfe
commit 1b4f6ed34e
9 changed files with 849 additions and 377 deletions
@@ -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")
+2
View File
@@ -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()
+37 -1
View File
@@ -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
+13
View File
@@ -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 ---
+122
View File
@@ -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 <adresse>' 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,
}
+2
View File
@@ -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(
+37 -4
View File
@@ -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 {
+11 -1
View File
@@ -10,7 +10,7 @@
<body>
<nav class="container-fluid">
<ul>
<li><strong>IMAP Mail Filter</strong></li>
<li class="brand">✉ Mail<span>Filter</span></li>
</ul>
<ul>
<li><a href="/">Dashboard</a></li>
@@ -29,6 +29,16 @@
<footer class="container">
<small>IMAP Mail Filter Service</small>
</footer>
<script>
// Aktiven Menüpunkt hervorheben
(function () {
const path = location.pathname;
document.querySelectorAll('nav a').forEach(a => {
const href = a.getAttribute('href');
if (href === '/' ? path === '/' : path.startsWith(href)) a.classList.add('active');
});
})();
</script>
<script src="/static/app.js"></script>
{% block scripts %}{% endblock %}
</body>
+594 -371
View File
File diff suppressed because it is too large Load Diff