93 lines
3.1 KiB
Python
93 lines
3.1 KiB
Python
from __future__ import annotations
|
|
|
|
import email
|
|
import imaplib
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
from email.message import Message
|
|
|
|
from app.config import InboxConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class IMAPError(Exception):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class IMAPMessage:
|
|
uid: str
|
|
raw: bytes
|
|
seen: bool
|
|
message: Message
|
|
|
|
|
|
class IMAPClient:
|
|
def __init__(self, inbox: InboxConfig):
|
|
self.inbox = inbox
|
|
self.conn: imaplib.IMAP4 | imaplib.IMAP4_SSL | None = None
|
|
|
|
def __enter__(self) -> "IMAPClient":
|
|
if not self.inbox.username or not self.inbox.password:
|
|
raise IMAPError(f"Missing IMAP credentials for inbox {self.inbox.id}")
|
|
cls = imaplib.IMAP4_SSL if self.inbox.imap_ssl else imaplib.IMAP4
|
|
self.conn = cls(self.inbox.imap_host, self.inbox.imap_port)
|
|
typ, _ = self.conn.login(self.inbox.username, self.inbox.password)
|
|
if typ != "OK":
|
|
raise IMAPError(f"IMAP login failed for {self.inbox.id}")
|
|
logger.info("IMAP connection succeeded for inbox %s", self.inbox.id)
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
if self.conn is None:
|
|
return
|
|
try:
|
|
self.conn.logout()
|
|
except Exception:
|
|
pass
|
|
|
|
def select_folder(self, folder: str) -> None:
|
|
assert self.conn is not None
|
|
typ, data = self.conn.select(f'"{folder}"', readonly=False)
|
|
if typ != "OK":
|
|
raise IMAPError(f"IMAP folder does not exist or cannot be selected: {folder}")
|
|
|
|
def search_uids(self, *, unread_only: bool, since: date | None = None, before: date | None = None, limit: int | None = None) -> list[str]:
|
|
assert self.conn is not None
|
|
criteria: list[str] = ["UNSEEN" if unread_only else "ALL"]
|
|
if since:
|
|
criteria.extend(["SINCE", since.strftime("%d-%b-%Y")])
|
|
if before:
|
|
criteria.extend(["BEFORE", before.strftime("%d-%b-%Y")])
|
|
typ, data = self.conn.uid("SEARCH", None, *criteria)
|
|
if typ != "OK":
|
|
raise IMAPError("IMAP UID search failed")
|
|
uids = data[0].decode().split() if data and data[0] else []
|
|
return uids[:limit] if limit else uids
|
|
|
|
def fetch_message(self, uid: str) -> IMAPMessage:
|
|
assert self.conn is not None
|
|
typ, data = self.conn.uid("FETCH", uid, "(RFC822 FLAGS)")
|
|
if typ != "OK" or not data:
|
|
raise IMAPError(f"Could not fetch UID {uid}")
|
|
raw = b""
|
|
flags = b""
|
|
for item in data:
|
|
if isinstance(item, tuple):
|
|
flags += item[0]
|
|
raw = item[1]
|
|
return IMAPMessage(uid=uid, raw=raw, seen=b"\\Seen" in flags, message=email.message_from_bytes(raw))
|
|
|
|
def mark_seen(self, uid: str) -> None:
|
|
assert self.conn is not None
|
|
self.conn.uid("STORE", uid, "+FLAGS", "(\\Seen)")
|
|
|
|
def move(self, uid: str, folder: str) -> None:
|
|
assert self.conn is not None
|
|
typ, _ = self.conn.uid("COPY", uid, f'"{folder}"')
|
|
if typ == "OK":
|
|
self.conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
|
|
self.conn.expunge()
|