Plugin surface: a sync cannot keep any state between runs, so postulo-imap reads unrecognised mail again on every pass #1165
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#1165
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?
What exists today
The scout read the stale local checkout; on the forge's
main(7880562) postulo-imap#20 already narrowed this:src/postulo_imap/sync.py:266-279: withmark = nothing, or when the folder'sPERMANENTFLAGSrefuse new keywords (mailbox.py:344-348),use_keywordis false andunread_uids(mailbox.py:350-367) searchesSINCE <today - days>alone, so every pass gets the whole look-back (30 days by default, up to 3,650).sync.py:288-293: each batch of 25 is surveyed (RFC822.SIZEand theMessage-IDheader, cheap) and_known(sync.py:358-373) drops the messages that already have a suggestion. That covers recognised mail only.sync.py:377-379returns"skipped"), so_knownnever knows it, andMailbox.read(mailbox.py:400-420) downloads it again, whole up to 1 MB, on every pass. In a Jobs folder most messages are of that kind: job alerts, newsletters, acknowledgements the rules do not match. The same is true of a message that could not be parsed.tests/test_imap.py:133-139answersSELECTwithEXISTSandPERMANENTFLAGSonly;test_leaving_the_mailbox_untouched(line 712) checks a recognised message, which is the case already handled.The protocol supports the fix everywhere: RFC 3501 §2.3.1.1 (and RFC 9051 §2.3.1.1) makes UIDs strictly ascending within a folder for as long as its
UIDVALIDITYis unchanged, andSELECTreturnsUIDVALIDITYandUIDNEXT(§6.3.1, §7.1). IMAPClient 3.1.0 hands both back inselect_folder's answer (imapclient.py:816-817). The core has nowhere for a plugin to keep them:Connection(src/postulo/plugins/models.py:283-305) hasconfig, secrets andlast_report,SyncReport(plugins/base.py:1028-1057) has counters and notes, and no plugin writes toconfigtoday.What would change
Core (one issue on postulo, shared with PR4 and PR5): a small per-connection state a sync plugin reads at the start of a run and returns at the end, for example
SyncReport.state: dict | None, saved by_run_connectioninto a newConnection.sync_stateJSONField only when the run did not fail, and dropped when the connection's configuration (server, folder, account) is edited. It goes on the plugin surface (plugins/api.py) and inpostulo.plugins.testing's surface check. Bounded in size (a few kB) so it cannot become a store.postulo-imap (one issue): in the modes that do not flag, remember
{folder, uidvalidity, last_uid}. At the next pass, if the folder andUIDVALIDITYmatch, searchUID <last_uid+1>:* SINCE <date>and drop any UID not abovelast_uidfrom the answer (RFC 3501 §6.4.8:n:*always includes the highest UID even when it is belown). IfUIDVALIDITYchanged, read the look-back as today. Recordlast_uidonly for batches that were read to the end, so a pass cut short resumes where it stopped. The keyword mode is unchanged. The test double gainsUIDVALIDITY/UIDNEXTin itsSELECTanswer and aUIDrange insearch.A side effect worth stating in the README: lengthening the look-back no longer reaches back for mail already passed over; a changed
daysshould reset the state, which the core's "drop on configuration edit" gives for free.CONDSTORE/QRESYNC (RFC 7162) is not needed: the plugin never revisits a message, so new UIDs are all it asks.
What it saves or protects
medium: in Leave the mailbox untouched mode, or on a folder that refuses keywords, a pass reads only what arrived since the last one; today it downloads the whole source of every unrecognised message of the look-back each time (for example 280 newsletters of 50 kB is about 14 MB per pass, 336 MB a day at the hourly default; an estimate, not measured)
Accessibility
none
Decided by the maintainer (2026-10-31)
per-connection state on the plugin surface: one bounded JSON field written only after a run that did not fail
Decisions
Found by the efficiency and reliability audit of 2026-10-30 (finding PR2; index postulo/postulo#1108). Checked against
mainatdaee30b15(2026-10-30).The other half of this finding is postulo/postulo-imap#34.