Initial commit
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user