Initial commit

This commit is contained in:
2026-05-16 12:05:36 -03:00
parent 0ce972a361
commit e82cee97a7
65 changed files with 9051 additions and 5 deletions
+92
View File
@@ -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()