Sync: every message of a pass is fetched whole, attachments included, in one command with no size limit #20
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#20
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, reading how a pass fetches its messages and measuringMailbox.fetchandparseon a message with an attachment.A pass asks the server for the complete source of every message the search returned, in a single
FETCH, and holds all of it in memory at once in order to keep at most 2,000 characters of each. Nothing bounds a message's size or the number of messages, so the memory a pass needs is set by whatever is in the folder. In Leave the mailbox untouched mode the same download is repeated on every pass.src/postulo_imap/mailbox.py:195-208,Mailbox.fetch:answer = self.client.fetch(uids, ["BODY.PEEK[]"]), withuidsbeing every result of the search (src/postulo_imap/sync.py:171-175). IMAPClient 3.1.0 joins all the ids into one command and returns one dictionary, so the whole answer exists before the first message is parsed.src/postulo_imap/mailbox.py:134-157,parse:email.message_from_bytes(raw)builds a second, parsed copy of each message, attachments included.src/postulo_imap/mailbox.py:43-45and:111:EXCERPT_CHARS = 2000is applied at the very end ofplain_text, after everything was downloaded and parsed.src/postulo_imap/mailbox.py:185-188,unread_uids: without the keyword the search isSINCE <date>alone, so Leave the mailbox untouched (and Move it to a subfolder whenever the move fails) returns the whole look-back window on every pass.src/postulo_imap/mailbox.py:3-7says "What is read is deliberately small", andREADME.md:73-74"no attachments". That is true of what is stored, not of what is transferred and held.fetchpassed twenty uids to the server in one call with['BODY.PEEK[]'].plugins/syncing.py:110-137in postulo) and in the web worker that serves Sync now (plugins/views.py:455). On a small self-hosted machine or a container with a memory limit, the process is killed.sync_connectionwritessynced_atonly afterplugin.syncreturns (plugins/syncing.py:86), so a killed pass leaves the connection due and the next pass starts the same download again; sync connections that come after it in the pass do not run.suggest()that it was already filed.TIMEOUT(mailbox.py:51) anywhere in the transfer loses the entire pass, and the next one starts from the beginning.RFC822.SIZEtogether with the headers first. Above a limit (1 MB, say) fetchBODY.PEEK[HEADER]and a bounded partial of the text,BODY.PEEK[TEXT]<0.65536>, and parse the two joined. Checked on the snapshot: the headers plus the first 64 KiB of the body of the message above giveparsethe same subject, identifier and text as the whole message. Choosing the first text part fromBODYSTRUCTUREis the more exact alternative.BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)]first and skip every message whose identifier already has a suggestion for this owner and source.Done when
tests/test_imap.pyhas a test whose fake client records eachfetchcall: a folder of 120 messages is never asked for in one call of more than the batch size, and a message whoseRFC822.SIZEis above the limit is never asked for withBODY.PEEK[]. Both fail today (one call, all uids,BODY.PEEK[]).tests/test_imap.pyhas a test in which a second pass in untouched mode asks for no message body of a message that already has a suggestion; today it fetches all of them.Fixed by pull request #25.