For senders whose mail body is the receipt (e.g. GoCardless payouts)
there is no attachment to file. The new action "Als PDF anhängen +
nach <Ordner>" renders the mail like a print from a mail client
(header block From/To/Date/Subject, then the content) and replaces it
in the target folder with: original content + PDF + the original mail
byte-for-byte as Original.eml, so the unaltered original is retained
(GoBD).
- Rendering with WeasyPrint. Images are fetched by us and inlined as
data: URIs; WeasyPrint itself may only read data:. Fetching is
restricted to http(s), public addresses (private, loopback,
link-local and PDF_BLOCKED_NETWORKS, default 172.0.0.0/12, are
refused; redirects are checked hop by hop), with time and size
limits. cid: images come from the MIME parts.
- The replacement is APPENDed with the original flags and INTERNALDATE,
verified in the target via its new Message-ID, and only then is the
original deleted. Already converted mails are only moved.
- "PDF-Vorschau" in the rule dialog renders the newest matching mail
read-only and opens it in the browser.
- List/log show 📄 targets; warnings for PDF + move/delete and for
PDF + "Hat Anhang = Ja"; log folder search covers PDF targets.
- Docker image gets Pango and fonts (~316 MB total).
Tested with the real GoCardless mail (3 remote images, rendered both
locally and inside the built image) and a simulated IMAP server for
success, rejected APPEND, missing copy and already converted mails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server dropped the connection mid-poll once ("Broken pipe"); the
scheduler kept using the dead connection, so the remaining ~400 mails
of the batch each failed and were still marked as processed — i.e.
silently never evaluated again.
- Fetch retries once after reconnecting on connection-level errors
(IMAP4.abort, OSError incl. timeouts/SSL). If the reconnect fails,
the poll stops with a single warning, keeps the progress so far and
leaves the remaining mails unmarked for the next poll.
- A failed action followed by a dead connection triggers a reconnect;
that mail is not marked as processed, so it is retried next poll.
- IMAPClient.reconnect() / is_alive() helpers.
Checked with a simulated server: single drop (all mails processed, no
errors), permanent outage (one warning, rest unmarked), drop during a
move (mail left unmarked for retry).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Fehlgeschlagene neu einlesen" reported the same 29 mails on every
click: it selected all error log entries ever written and never noted
that one had already been retried.
Error log entries now get retried_at when they are picked up; the
endpoint only considers entries without it. If the mail fails again,
the scheduler writes a new, open error entry, so a second click right
after the first reports 0. The log shows retried errors greyed out
with "↻ erneut versucht <Zeit>", and the result dialog says how many
open error entries and mails were requeued.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A spam mail matched stefanhacker@gmx.de but "Aktion fehlgeschlagen"
every time: it has no Message-ID header, and the safe move refused to
proceed because it verifies the copy by Message-ID. So every mail
without that header stayed in INBOX forever, and the log didn't say why.
Verification now falls back when there is no Message-ID:
- COPYUID from the COPY response when the server supports UIDPLUS
- otherwise count mails in the target with the same exact size and
sender before and after COPY; exactly one more means it arrived
The source is still only flagged/expunged after verification, and
move_mail now refuses to run without source_folder (the verification
selects the target, so STORE could otherwise hit the wrong folder).
Checked against a simulated IMAP server for success, rejected COPY,
copy not arriving, and missing source folder.
IMAPClient.last_error carries the reason (rejected COPY with server
text, copy not found incl. how it was checked, missing target folder,
no SMTP config, ...); the scheduler appends it to the log message.
"Fehlgeschlagene neu einlesen" now also retries failed actions, not
only fetch errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The existing daily cleanup never effectively ran: it was an interval
job counted from container start (reset on every deploy), it called
asyncio.get_event_loop() from a worker thread, and even when deleting,
SQLite never returns freed pages to the filesystem. Live DB: 4.1 GB,
of which 2.6 GB log and 0.6 GB unused pages.
New maintenance service, scheduled daily at MAINTENANCE_HOUR_UTC:30
and once 5 minutes after every start:
- delete log entries older than LOG_RETENTION_DAYS (30) and keep at
most LOG_MAX_ENTRIES (200k) newest ones
- delete processed_mails markers of deleted rules/accounts (FKs are
not enforced by SQLite)
- VACUUM when >25% of the file is unused, only if free disk space
covers the copy; never runs twice in parallel
GET/POST /api/logs/maintenance expose status and a manual run; the log
menu shows DB size, entry count and last run, with "Datenbank
aufräumen & verkleinern". Removed the dead cleanup_old_logs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The log had 6.45M rows, of which 434 were actual actions: every mail x
every non-matching rule wrote "Keine Übereinstimmung" (5.3M rows, ~1 GB),
plus "keine neuen Mails" per rule every 30s and a "Poll gestartet" row.
Free-text searches took 50-70s.
- Routine entries are only written with LOG_VERBOSE=true; the poll
summary is logged only when there were new mails, actions or errors.
"Why didn't my rule match" is now answered by the rule tester.
- The scheduler commits every 25 evaluated mails and after each rule,
since the dropped log calls used to be the implicit commit points.
- Migration rebuilds filter_logs without the routine rows (live:
6.45M -> ~38k) via copy instead of DELETE.
- /api/logs: keyset pagination (before_id) instead of OFFSET, count
capped at 1000, since/until range, multi-level filter, per-field
filters; ordner: also matches the move target stored in details.
- New log page: one search field with shortcuts (von:, betreff:,
regel:, ordner:, text:), live search with debounce and abort, active
filters as removable chips, click on rule/sender/folder in a row to
filter, time range (default 7 days), level quick filter, expandable
row details, "Ältere laden", filters kept in the URL.
- Disable Pico's colour transition on segmented buttons (they briefly
flashed in the primary colour on load).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The match column now shows "27 · 22.09. 12:00", with the full date and
time in the tooltip. Server timestamps are naive UTC; appending 'Z'
before parsing keeps the browser from treating them as local time,
which also fixes mail dates in the rule test results.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pico scales the root font up to 125% on wide screens, which made the
whole admin UI oversized. Pin it to 93.75% and tighten spacing tokens.
The filter list is now a dense table-like list: switch, priority, name,
condition chips, target folder, match stats and icon buttons on a single
row (~35 rules fit on one screen instead of ~4). Warnings collapse into
a ⚠ icon with tooltip; attachment and date conditions render as short
chips; the full condition list is in the row tooltip; double-click opens
the rule. Account picker, summary and main actions share one header row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- IMAP connections now use a 120s socket timeout. Without it the poll
hung for 5 days in logout() on a silently dropped connection; every
later poll was skipped ("max instances reached"), so nothing got
sorted and newly created rules were never evaluated.
- Header decoding joined decode_header() parts with " " although the
whitespace is already part of the unencoded chunks, producing
"IT-Mönch <noreply@…>" (two spaces) — the it-moench rule could never
match. Join without separator and collapse whitespace/folded lines.
- Rule text matching (contains/exact) normalizes whitespace on both
sides, so stray or doubled spaces in rule values no longer break them.
- Pseudo charsets (unknown-8bit, …) try UTF-8 before latin-1, fixing
mojibake like "nächster" in raw 8-bit headers.
- Fetch via BODY.PEEK[] instead of RFC822, which set \Seen on every
evaluated mail. Payload is located by shape in the FETCH response,
fixing "'int' object has no attribute 'decode'".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live poll had been silently dying every 30s with
"Fehler beim Abrufen von Ordner 'INBOX' — got more than 1000000 bytes"
Root cause: Python's imaplib hard-caps a single response at 1 MB. With
~150k UIDs in INBOX, "UID SEARCH ALL" returns a longer line than that,
imaplib throws, get_all_uids() returns [], the poll reports "0 Mail(s)
im Ordner, 0 Treffer" and nothing gets sorted. Bump _MAXLINE to 100 MB
(covers mailboxes with millions of mails).
Filter search haystack now also includes the German condition/action
labels ("am (exakt)", "ab / nach", "verschieben in Ordner", …) and a
"stop" token, so the user can spot mis-typed date rules by typing
"am" and see any on_date rules rather than having to know the
internal enum names.
Also audited every rule via SSH: no on_date/date-exact rules exist —
all 32 rules use `date after '2026-01-01'` correctly. cps datensysteme
still has its known duplicate conditions (harmless).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/filters/reset-all-processed drops and recreates the
processed_mails table — sekundenschnell auch bei Millionen Zeilen
via DROP+CREATE (same pattern as the fast clear-logs). Safe because
already-moved mails are no longer in the source folder, so a fresh
re-evaluation cannot re-move them, only pick up ones that were
skipped due to a rule bug (e.g. the has_attachment fix).
Button "Alle Regeln zurücksetzen" next to "Neue Regel" on the filter
list, with a confirmation dialog and result feedback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cps datensysteme rule (from cps-datensysteme.de + subject "Rechnung"
+ has_attachment true + date after) was failing on real invoices from
CPS ORMS. Their PDFs arrive without a Content-Disposition header — the
filename lives only on the Content-Type parameter — so _has_attachment
returned False and the rule never matched.
Extended the check to also count a part as an attachment when it has a
filename() and its content-type is neither text/* nor multipart/*.
That catches PDFs, Office and image attachments regardless of whether
the sender set Content-Disposition, without treating body parts as
attachments. Verified against four synthetic cases including the exact
CPS ORMS shape.
Same helper is now applied to non-multipart mails too, in case an entire
mail is a single attached file (rare but possible).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-rule batch was sliced from all_uids in server order (oldest
UID first), so with a big backlog the scheduler chewed through years
of unmatched newsletters before reaching the current invoices the
user was actually waiting on. Sort new_uids descending by int (with a
plain reverse() fallback for non-numeric UIDs) so each poll picks the
newest 500 first. Old backlog still gets worked off in the background.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- fix: "Log leeren" without account filter used a plain DELETE over
millions of rows. That held the sqlite write lock long enough that
the scheduler's own log writes started failing with
"database is locked", and the request itself eventually 500'd back
as HTML "Internal Server Error" — which the frontend then tried to
JSON.parse, producing the popup the user reported. Now uses
DROP+CREATE for full clears (near-instant) and wal_checkpoint to
reclaim space; per-account clear still uses a normal DELETE.
- Raise sqlite busy_timeout from 10s to 60s so a legitimately slow
writer no longer starves smaller ones.
- retry-failed: single bulk DELETE with mail_uid IN (...) per account
instead of one query per uid.
- Both endpoints now return proper HTTPException(500, ...) with detail
instead of letting the exception bubble as plain text.
- Frontend: parseResponse() reads text first, then tries JSON. Both
clearLogs() and retryFailed() surface the actual server message
instead of crashing on non-JSON responses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- fix: fetch_mail crashed on MIME pseudo-encodings like 'unknown-8bit',
'x-unknown', '8bit'. New _safe_decode helper maps those (and any
LookupError from unknown codecs) to latin-1, which never fails on
8-bit input. Used in _extract_body and _decode_header_value.
- Consequence of the crash: scheduler marked the affected UIDs as
processed to avoid retry loops, so those mails never got sorted
even after the underlying issue was fixable.
- feat: POST /api/filters/retry-failed drops the ProcessedMail markers
for UIDs that appear in "Fehler beim Abrufen" error logs, so the
next poll re-evaluates them. Reachable via a button in the log UI
("Fehlgeschlagene neu einlesen"), optionally scoped per account.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Substring search across rule name, source folder, condition values
and action parameters. Filters the table client-side as the user
types and shows a match counter (e.g. "3 / 12").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New filter rules no longer default to priority 100. Server picks
max(priority)+10 per account when client sends priority=None (or 10
if the account has no rules yet), and the UI pre-fills the input
with the next value instead of a fixed 100. Avoids ambiguous ordering
when multiple rules end up with the same priority.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- fix: Starlette 1.0 changed TemplateResponse signature — request is now
the first positional argument, not nested inside the context dict.
All HTML routes returned 500 (unhashable dict in jinja2 template cache)
after the image rebuild picked up the new starlette version.
- fix: processed_mails (1.7M rows in production: 1 account × 12 rules ×
141k inbox mails) made backup export hit 558 MB / 90s. Moved to opt-in
checkbox in the UI alongside the logs option, default off.
- yield_per for streaming the processed_mails query when included.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- fix: imap folder names with spaces (RFC3501 quoting in move/create/select)
- fix: move only deletes source after COPY + Message-ID verification in target
- fix: backup endpoint hung on sqlite write locks — enable WAL + busy_timeout
- perf: indexes on filter_logs(created_at, level, account_id+created_at) for
fast log queries on millions of rows
- feat: optional "logs mit sichern" checkbox in backup export, restore on import
- UI: backup download uses fetch+blob with error display instead of location.href
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>