diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py index 95c5b8fec..0c1e7c048 100644 --- a/src/paperless_mail/mail.py +++ b/src/paperless_mail/mail.py @@ -680,9 +680,38 @@ class MailAccountHandler(LoggingMixin): f"Rule {rule}: Searching folder with criteria {criterias}", ) + try: + all_uids = set( + M.uids(criteria=criterias, charset=rule.account.character_set), + ) + except Exception as err: + raise MailError( + f"Rule {rule}: Error while searching folder {rule.folder}", + ) from err + + processed_uids_qs = ProcessedMail.objects.filter( + rule=rule, + folder=rule.folder, + uid__in=all_uids, + ) + if self._current_uid_validity is not None: + processed_uids_qs = processed_uids_qs.filter( + Q(uid_validity=self._current_uid_validity) + | Q(uid_validity__isnull=True), + ) + processed_uids = set(processed_uids_qs.values_list("uid", flat=True)) + + new_uids = all_uids - processed_uids + + if not new_uids: + self.log.debug( + f"Rule {rule}: No new mail matching criteria {criterias}", + ) + return 0 + try: messages = M.fetch( - criteria=criterias, + criteria=AND(uid=list(new_uids)), mark_seen=False, charset=rule.account.character_set, bulk=True, diff --git a/src/paperless_mail/tests/test_mail.py b/src/paperless_mail/tests/test_mail.py index b2b055582..97f1aa824 100644 --- a/src/paperless_mail/tests/test_mail.py +++ b/src/paperless_mail/tests/test_mail.py @@ -964,6 +964,62 @@ class TestMail( ] self.assertEqual(queued_rule.id, first_rule.id) + def test_handle_mail_account_skips_body_fetch_for_already_processed_mail( + self, + ) -> None: + """ + GIVEN: + - An attachment-less mail under an attachments-only mark-read rule, + already recorded as PROCESSED_WO_CONSUMPTION + WHEN: + - The mail account is processed again and the mail still matches the + search criteria (it was never marked read, since no mail action is + applied for the no-consumption case) + THEN: + - No IMAP body fetch happens for that mail; only the cheap UID search runs. + """ + account = MailAccount.objects.create( + name="test", + imap_server="", + username="admin", + password="secret", + ) + rule = MailRule.objects.create( + name="testrule", + account=account, + action=MailRule.MailAction.MARK_READ, + consumption_scope=MailRule.ConsumptionScope.ATTACHMENTS_ONLY, + ) + + message = self.mailMocker.messageBuilder.create_message( + subject="No attachment", + attachments=[], + ) + self.mailMocker.bogus_mailbox.messages = [message] + self.mailMocker.bogus_mailbox.updateClient() + + # First run: records ProcessedMail without consuming anything. + self.mail_account_handler.handle_mail_account(account) + self.assertTrue( + ProcessedMail.objects.filter( + rule=rule, + uid=message.uid, + folder=rule.folder, + ).exists(), + ) + self.mailMocker._queue_consumption_tasks_mock.assert_not_called() + + # Second run: message still matches UNSEEN (mark-read action never ran), + # but its body must not be downloaded again. + with mock.patch.object( + self.mailMocker.bogus_mailbox, + "fetch", + wraps=self.mailMocker.bogus_mailbox.fetch, + ) as fetch_spy: + self.mail_account_handler.handle_mail_account(account) + + fetch_spy.assert_not_called() + def test_handle_mail_account_skip_duplicate_uids_from_fetch(self) -> None: """ GIVEN: