Sync: one message that cannot be parsed fails the whole pass, and every pass after it #8
Labels
No labels
bug
documentation
enhancement
performance
security
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo-imap#8
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo-imap at
cc09c6381, runningparseandImapSync.syncagainst malformed messages.Mailbox.fetchparses every message of a pass in one loop with nothing around each one, andparsehas reachable inputs that raise an exception nobody catches. The exception leavessync()before anything is flagged or moved, so no message of that pass produces a suggestion, and the next pass fetches the same message and fails the same way. The folder stays unread until the message is older than the look-back (thirty days by default) or the person finds it and removes it by hand.Where:
src/postulo_imap/mailbox.py:204-207,Mailbox.fetch:mails.append(parse(int(uid), raw))with notry.src/postulo_imap/mailbox.py:138-142,parse:email.utils.parsedate_to_datetime(message["Date"])underexcept (TypeError, ValueError).src/postulo_imap/mailbox.py:121-125,_payload:raw.decode(charset, errors="replace")underexcept LookupError.src/postulo_imap/sync.py:175(the fetch) andsrc/postulo_imap/sync.py:214-217(the flag or the move, never reached);sync()has onlyfinally: close(client).What happens: three inputs, the first two run against the snapshot on Python 3.14, which is what the image ships.
Dateheader that contains a raw 8-bit byte. The compat32 parser hands such a header back as anemail.header.Headerobject, not astr, andparsedate_to_datetimecalls.split()on it:AttributeError: 'Header' object has no attribute 'split'.AttributeErroris not in the tuple that is caught.errors="replace".charset=idnaraisesUnicodeError: Unsupported error handling: replace;charset=undefinedraisesUnicodeError: undefined encoding. OnlyLookupErroris caught._decodedandplain_text(parsereturned'x\x00y'and'bo\x00dy') and reachessuggest()assummary,bodyandcontext. PostgreSQL refuses NUL intextandjsonbvalues, so on a PostgreSQL instance the insert raises inside the loop atsrc/postulo_imap/sync.py:188. This third case was not run (SQLite accepts NUL); an RFC 2047 encoded word or a base64 body is enough to carry one through any server.With a fake client holding one ordinary acknowledgement and one message of the first kind,
ImapSync().sync()raised theAttributeErrorwith nothing flagged and no suggestion filed. The core'ssync_connection(plugins/syncing.py:79-85) recordsAttributeError: 'Header' object has no attribute 'split'as the connection's last error and moves on; it cannot skip a message. Because the search isNOT KEYWORD $Postulo SINCE <date>, the same message comes back on every pass.Who can trigger it: anyone whose mail reaches the folder the connection reads: a recruiting system that writes a malformed header, or unsolicited mail the person's filter files there. No account on the instance is needed.
Why it matters: the plugin stops working for that person, with an error that names a Python attribute and not a message. Mail that arrives while the bad message is still inside the look-back is not read; mail that ages out together with it is never read at all, so its acknowledgements, rejections and invitations never become suggestions.
Fix:
Mailbox.fetch, parse each message inside its owntry/except Exception: log the uid and the exception, count it (askippedon the report and a note saying how many could not be read), and keep the uid in the list that is flagged or moved so it is not fetched again.sync(), give each message'ssuggest()call the same isolation, so one row the database refuses does not stop the rest.parse, passstr(message.get("Date"))or catchExceptionaround the date; catch(LookupError, UnicodeError)in_payload; remove NUL characters from every string before it leavesparse. Parsing withpolicy=email.policy.defaultremoves theHeader-object case altogether, at the price of re-checking the other header reads.Done when
tests/test_imap.pyhas a test thatparsereturns aMail(withdateNoneor the parsed date) for aDateheader carrying an 8-bit byte, forcharset=idnaand forcharset=undefined, and that no string of the returnedMailcontains NUL when the subject and the body did. All three fail today.tests/test_imap.pyhas a test that runs a pass over one good acknowledgement and one messageparsecannot read: the suggestion for the good one is filed, both are flagged$Postulo, and the report says one message could not be read. It fails today withAttributeError.From the 2026-10-16 security review: a fourth input that fails the whole pass, and it raises in
match(), not inparse()orsuggest().A message whose
Referencesheader carries more identifiers than the database accepts as bound parameters makesmatch()(matching.py:90-98, oneINclause over every identifier) raiseOperationalErrorinside the per-message loop ofsync()(sync.py:182-212). Nothing around it catches it;box.mark(uids)/box.move(...)run only after the loop (sync.py:214-217), so nothing is flagged and the next pass fails on the same message until it leaves the look-back window. The count itself is #15's to bound; this comment is about thetry.The fix proposed here wraps
parseinMailbox.fetchandsuggest()insync(). Please make the per-messagetryinsync()wrap the whole body of the loop,classify,match()andsuggest()together, so that any one message that raises becomesreport.skipped += 1plus a note and is still flagged.Done when a test makes
match()raise for one message of a pass and asserts the other messages are filed and flagged and the report names one skipped.