# retoor <retoor@molodetz.nl>
from __future__ import annotations
import email
import imaplib
import re
import smtplib
import ssl
from contextlib import contextmanager
from email.message import EmailMessage
from email.policy import default as default_policy
from email.utils import formataddr, getaddresses, parsedate_to_datetime
from typing import Any, Iterator
from devplacepy.net_guard import BlockedAddressError, guard_public_host_sync
from .config import EmailAccount
from .errors import EmailError
DEFAULT_TIMEOUT_SECONDS = 30.0
MAX_BODY_CHARS = 80_000
HEADER_FIELDS = "(FROM TO CC SUBJECT DATE MESSAGE-ID)"
SUMMARY_ITEMS = f"(UID FLAGS BODY.PEEK[HEADER.FIELDS {HEADER_FIELDS}])"
_UID_PATTERN = re.compile(rb"UID (\d+)")
def imap_quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def _header_addresses(value: str) -> list[str]:
if not value:
return []
return [formataddr(pair) for pair in getaddresses([value]) if pair[1]]
def _decode_date(raw: str) -> str | None:
if not raw:
return None
try:
return parsedate_to_datetime(raw).isoformat()
except (TypeError, ValueError):
return raw
class EmailClient:
def __init__(
self, account: EmailAccount, timeout: float = DEFAULT_TIMEOUT_SECONDS
) -> None:
self.account = account
self.timeout = timeout
@contextmanager
def _imap(self) -> Iterator[imaplib.IMAP4]:
account = self.account
if not account.imap_host:
raise EmailError("This account has no IMAP host configured.", kind="config")
try:
guard_public_host_sync(account.imap_host)
except BlockedAddressError as exc:
raise EmailError(str(exc), kind="blocked") from exc
context = ssl.create_default_context()
try:
if account.imap_ssl:
conn: imaplib.IMAP4 = imaplib.IMAP4_SSL(
account.imap_host,
account.imap_port,
ssl_context=context,
timeout=self.timeout,
)
else:
conn = imaplib.IMAP4(
account.imap_host, account.imap_port, timeout=self.timeout
)
if account.imap_starttls:
conn.starttls(context)
conn.login(account.username, account.password)
except (OSError, imaplib.IMAP4.error) as exc:
raise EmailError(f"IMAP connection failed: {exc}", kind="connect") from exc
try:
yield conn
finally:
try:
conn.logout()
except (OSError, imaplib.IMAP4.error):
pass
def _select(self, conn: imaplib.IMAP4, folder: str, readonly: bool) -> None:
typ, data = conn.select(imap_quote(folder), readonly=readonly)
if typ != "OK":
raise EmailError(f"Cannot open folder '{folder}': {_first(data)}")
def _uid_search(self, conn: imaplib.IMAP4, criteria: list[str]) -> list[str]:
if not criteria:
typ, data = conn.uid("SEARCH", None, "ALL")
else:
args: list[Any] = [b"CHARSET", b"UTF-8"]
args.extend(token.encode("utf-8") for token in criteria)
typ, data = conn.uid("SEARCH", *args)
if typ != "OK":
raise EmailError(f"Search failed: {_first(data)}")
if not data or not data[0]:
return []
return [chunk.decode() for chunk in data[0].split()]
def list_folders(self) -> list[dict[str, Any]]:
with self._imap() as conn:
typ, data = conn.list()
if typ != "OK":
raise EmailError(f"Cannot list folders: {_first(data)}")
folders: list[dict[str, Any]] = []
for entry in data or []:
if entry is None:
continue
line = entry.decode("utf-8", "replace") if isinstance(entry, bytes) else str(entry)
match = re.match(r"\((?P<flags>[^)]*)\) \"?(?P<delim>[^\"]*)\"? (?P<name>.+)", line)
if not match:
continue
name = match.group("name").strip().strip('"')
folders.append({"name": name, "flags": match.group("flags").split()})
return folders
def list_messages(
self,
folder: str,
criteria: list[str],
limit: int,
offset: int,
) -> dict[str, Any]:
with self._imap() as conn:
self._select(conn, folder, readonly=True)
uids = self._uid_search(conn, criteria)
uids.reverse()
total = len(uids)
window = uids[offset : offset + limit]
messages = self._fetch_summaries(conn, window)
return {
"folder": folder,
"total": total,
"offset": offset,
"limit": limit,
"count": len(messages),
"messages": messages,
}
def _fetch_summaries(
self, conn: imaplib.IMAP4, uids: list[str]
) -> list[dict[str, Any]]:
if not uids:
return []
typ, data = conn.uid("FETCH", ",".join(uids), SUMMARY_ITEMS)
if typ != "OK":
raise EmailError(f"Fetch failed: {_first(data)}")
order = {uid: index for index, uid in enumerate(uids)}
summaries: list[dict[str, Any]] = []
for part in data or []:
if not isinstance(part, tuple) or len(part) < 2:
continue
descriptor, header_bytes = part[0], part[1]
uid_match = _UID_PATTERN.search(descriptor or b"")
if not uid_match:
continue
uid = uid_match.group(1).decode()
flags = [flag.decode() if isinstance(flag, bytes) else str(flag) for flag in imaplib.ParseFlags(descriptor)]
headers = email.message_from_bytes(header_bytes or b"", policy=default_policy)
summaries.append(
{
"uid": uid,
"flags": flags,
"seen": "\\Seen" in flags,
"from": str(headers.get("From", "")),
"to": _header_addresses(str(headers.get("To", ""))),
"subject": str(headers.get("Subject", "")),
"date": _decode_date(str(headers.get("Date", ""))),
"message_id": str(headers.get("Message-ID", "")),
}
)
summaries.sort(key=lambda item: order.get(item["uid"], 0))
return summaries
def read_message(self, folder: str, uid: str) -> dict[str, Any]:
with self._imap() as conn:
self._select(conn, folder, readonly=True)
typ, data = conn.uid("FETCH", uid, "(FLAGS BODY.PEEK[])")
if typ != "OK":
raise EmailError(f"Fetch failed: {_first(data)}")
raw = _first_payload(data)
if raw is None:
raise EmailError(f"Message {uid} not found in '{folder}'.", kind="not_found")
flags = [flag.decode() if isinstance(flag, bytes) else str(flag) for flag in imaplib.ParseFlags(_first_descriptor(data) or b"")]
message = email.message_from_bytes(raw, policy=default_policy)
return self._render_message(uid, folder, flags, message)
def _render_message(
self, uid: str, folder: str, flags: list[str], message: Any
) -> dict[str, Any]:
text_part = message.get_body(preferencelist=("plain",))
html_part = message.get_body(preferencelist=("html",))
attachments: list[dict[str, Any]] = []
for part in message.iter_attachments():
payload = part.get_content()
size = len(payload) if isinstance(payload, (bytes, str)) else 0
attachments.append(
{
"filename": part.get_filename() or "",
"content_type": part.get_content_type(),
"size": size,
}
)
body = text_part.get_content().strip() if text_part else ""
html = html_part.get_content().strip() if html_part else ""
return {
"uid": uid,
"folder": folder,
"flags": flags,
"from": str(message.get("From", "")),
"to": _header_addresses(str(message.get("To", ""))),
"cc": _header_addresses(str(message.get("Cc", ""))),
"subject": str(message.get("Subject", "")),
"date": _decode_date(str(message.get("Date", ""))),
"message_id": str(message.get("Message-ID", "")),
"body": body[:MAX_BODY_CHARS],
"body_truncated": len(body) > MAX_BODY_CHARS,
"html": html[:MAX_BODY_CHARS],
"attachments": attachments,
}
def search(self, folder: str, criteria: list[str], limit: int) -> dict[str, Any]:
return self.list_messages(folder, criteria, limit, 0)
def set_flags(
self, folder: str, uid: str, flags: list[str], add: bool
) -> dict[str, Any]:
with self._imap() as conn:
self._select(conn, folder, readonly=False)
command = "+FLAGS" if add else "-FLAGS"
typ, data = conn.uid("STORE", uid, command, "(%s)" % " ".join(flags))
if typ != "OK":
raise EmailError(f"Flag update failed: {_first(data)}")
return {"uid": uid, "folder": folder, "flags": flags, "added": add}
def move_message(self, folder: str, uid: str, destination: str) -> dict[str, Any]:
with self._imap() as conn:
self._select(conn, folder, readonly=False)
typ, data = conn.uid("COPY", uid, imap_quote(destination))
if typ != "OK":
raise EmailError(f"Copy to '{destination}' failed: {_first(data)}")
conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
conn.expunge()
return {"uid": uid, "from_folder": folder, "to_folder": destination}
def delete_message(
self, folder: str, uid: str, trash: str | None
) -> dict[str, Any]:
if trash and trash != folder:
result = self.move_message(folder, uid, trash)
result["deleted"] = True
return result
with self._imap() as conn:
self._select(conn, folder, readonly=False)
typ, data = conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
if typ != "OK":
raise EmailError(f"Delete failed: {_first(data)}")
conn.expunge()
return {"uid": uid, "folder": folder, "deleted": True}
def send_message(
self,
to: list[str],
subject: str,
body: str,
cc: list[str] | None = None,
bcc: list[str] | None = None,
html: str | None = None,
in_reply_to: str | None = None,
) -> dict[str, Any]:
account = self.account
if not account.smtp_host:
raise EmailError("This account has no SMTP host configured.", kind="config")
message = EmailMessage()
message["From"] = (
formataddr((account.from_name, account.from_address))
if account.from_name
else account.from_address
)
message["To"] = ", ".join(to)
if cc:
message["Cc"] = ", ".join(cc)
message["Subject"] = subject
if in_reply_to:
message["In-Reply-To"] = in_reply_to
message["References"] = in_reply_to
message.set_content(body)
if html:
message.add_alternative(html, subtype="html")
recipients = list(to) + list(cc or []) + list(bcc or [])
try:
guard_public_host_sync(account.smtp_host)
except BlockedAddressError as exc:
raise EmailError(str(exc), kind="blocked") from exc
context = ssl.create_default_context()
try:
if account.smtp_ssl:
server: smtplib.SMTP = smtplib.SMTP_SSL(
account.smtp_host,
account.smtp_port,
timeout=self.timeout,
context=context,
)
else:
server = smtplib.SMTP(
account.smtp_host, account.smtp_port, timeout=self.timeout
)
if account.smtp_starttls:
server.starttls(context=context)
with server:
if account.username and account.password:
server.login(account.username, account.password)
server.send_message(
message, from_addr=account.from_address, to_addrs=recipients
)
except (OSError, smtplib.SMTPException) as exc:
raise EmailError(f"Sending failed: {exc}", kind="send") from exc
return {
"sent": True,
"from": account.from_address,
"to": to,
"cc": cc or [],
"bcc": bcc or [],
"subject": subject,
}
def _first(data: Any) -> str:
if not data:
return ""
head = data[0]
if isinstance(head, bytes):
return head.decode("utf-8", "replace")
return str(head)
def _first_descriptor(data: Any) -> bytes | None:
for part in data or []:
if isinstance(part, tuple) and part:
return part[0]
return None
def _first_payload(data: Any) -> bytes | None:
for part in data or []:
if isinstance(part, tuple) and len(part) >= 2:
return part[1]
return None