feat(email): support IMAP MOVE and UID expunge fallbacks
This commit is contained in:
committed by
Xubin Ren
parent
ec5460d23e
commit
4369eb20fc
+92
-15
@@ -16,6 +16,7 @@ from email.parser import BytesParser
|
||||
from email.utils import parseaddr
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from loguru import logger
|
||||
@@ -70,6 +71,13 @@ class EmailConfig(Base):
|
||||
max_attachments_per_email: int = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ServerFeatures:
|
||||
move: bool
|
||||
uidplus: bool
|
||||
uid_store: bool | None = None
|
||||
|
||||
|
||||
class EmailChannel(BaseChannel):
|
||||
"""
|
||||
Email channel.
|
||||
@@ -635,34 +643,103 @@ class EmailChannel(BaseChannel):
|
||||
return
|
||||
|
||||
try:
|
||||
features = self._server_features(client)
|
||||
# Apply all post-actions in one IMAP session. `features` also carries
|
||||
# session-learned behavior (e.g. UID STORE support) so later UIDs can
|
||||
# skip known-broken paths.
|
||||
for uid in post_actions_uids:
|
||||
if uid:
|
||||
self._apply_post_action(client, uid)
|
||||
self._apply_post_action(client, uid, features)
|
||||
finally:
|
||||
self._close_imap_client(client)
|
||||
|
||||
def _apply_post_action(self, client: Any, uid: str) -> None:
|
||||
status, data = client.search(None, "UID", uid)
|
||||
if status != "OK" or not data or not data[0]:
|
||||
self.logger.warning("Post-action skipped: UID {} not found", uid)
|
||||
return
|
||||
|
||||
imap_id = data[0].split()[0]
|
||||
def _apply_post_action(
|
||||
self,
|
||||
client: Any,
|
||||
uid: str,
|
||||
features: _ServerFeatures,
|
||||
) -> None:
|
||||
action = self.config.post_action
|
||||
|
||||
if action == "delete":
|
||||
client.store(imap_id, "+FLAGS", "\\Deleted")
|
||||
client.expunge()
|
||||
if not self._uid_store_deleted(client, uid, features):
|
||||
return
|
||||
self._uid_expunge_or_fallback(client, uid, features)
|
||||
return
|
||||
|
||||
if action == "move":
|
||||
target = (self.config.post_action_move_mailbox or "").strip()
|
||||
status, _ = client.copy(imap_id, target)
|
||||
if status != "OK":
|
||||
self.logger.warning("Post-action move failed for UID {} to mailbox {}", uid, target)
|
||||
if features.move:
|
||||
status, _ = client.uid("MOVE", uid, target)
|
||||
if status != "OK":
|
||||
self.logger.warning("Post-action move failed (UID MOVE) for UID {} to mailbox {}", uid, target)
|
||||
return
|
||||
client.store(imap_id, "+FLAGS", "\\Deleted")
|
||||
client.expunge()
|
||||
|
||||
status, _ = client.uid("COPY", uid, target)
|
||||
if status != "OK":
|
||||
self.logger.warning("Post-action move failed (UID COPY) for UID {} to mailbox {}", uid, target)
|
||||
return
|
||||
if not self._uid_store_deleted(client, uid, features):
|
||||
return
|
||||
self._uid_expunge_or_fallback(client, uid, features)
|
||||
|
||||
@staticmethod
|
||||
def _server_features(client: Any) -> _ServerFeatures:
|
||||
caps: set[str] = set()
|
||||
with suppress(Exception):
|
||||
status, data = client.capability()
|
||||
if status == "OK" and data:
|
||||
for raw in data:
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
caps.update(token.upper() for token in raw.decode("utf-8", errors="ignore").split())
|
||||
elif isinstance(raw, str):
|
||||
caps.update(token.upper() for token in raw.split())
|
||||
return _ServerFeatures(move="MOVE" in caps, uidplus="UIDPLUS" in caps)
|
||||
|
||||
@staticmethod
|
||||
def _lookup_imap_id_by_uid(client: Any, uid: str) -> bytes | None:
|
||||
# IMAP exposes two message identifiers: UID (stable) and sequence number
|
||||
# (session-local). We target by UID first, but some servers may reject
|
||||
# UID STORE. In that case we resolve the current sequence number for the
|
||||
# UID and retry with STORE using that sequence id.
|
||||
status, data = client.search(None, "UID", uid)
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return None
|
||||
return data[0].split()[0]
|
||||
|
||||
def _uid_store_deleted(self, client: Any, uid: str, features: _ServerFeatures) -> bool:
|
||||
# Optimistic path: try UID STORE first because UID is stable and avoids
|
||||
# sequence-number lookup. If this fails once for the session, remember it
|
||||
# and use the sequence STORE fallback directly for remaining UIDs.
|
||||
if features.uid_store is not False:
|
||||
status, _ = client.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
|
||||
if status == "OK":
|
||||
features.uid_store = True
|
||||
return True
|
||||
features.uid_store = False
|
||||
|
||||
# Compatibility fallback for servers where UID STORE is unavailable or
|
||||
# unreliable: resolve the current sequence number from UID and use STORE.
|
||||
imap_id = self._lookup_imap_id_by_uid(client, uid)
|
||||
if not imap_id:
|
||||
self.logger.warning("Post-action skipped: UID {} not found", uid)
|
||||
return False
|
||||
|
||||
status, _ = client.store(imap_id, "+FLAGS", "\\Deleted")
|
||||
if status != "OK":
|
||||
self.logger.warning("Post-action failed: could not mark UID {} as deleted", uid)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _uid_expunge_or_fallback(self, client: Any, uid: str, features: _ServerFeatures) -> None:
|
||||
# Prefer UID-scoped expunge when supported to avoid expunging unrelated
|
||||
# messages already marked \Deleted in the selected mailbox.
|
||||
if features.uidplus:
|
||||
status, _ = client.uid("EXPUNGE", uid)
|
||||
if status == "OK":
|
||||
return
|
||||
self.logger.warning("UID EXPUNGE failed for UID {}, falling back to EXPUNGE", uid)
|
||||
client.expunge()
|
||||
|
||||
@classmethod
|
||||
def _is_stale_imap_error(cls, exc: Exception) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user