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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user