Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""Pure parsing helpers for IMAP responses (unit-testable, no I/O).
|
||||
|
||||
aioimaplib returns responses as lists of ``bytes``/``bytearray`` lines. A FETCH
|
||||
with ``BODY[]`` yields, per message, a header line ending ``... {<size>}`` followed
|
||||
by a ``bytearray`` literal, then a lone ``)``. LIST lines carry SPECIAL-USE flags.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from imapclient.imap_utf7 import decode as imap_utf7_decode
|
||||
|
||||
# --- SPECIAL-USE mapping (SPEC §4 special_use values) -----------------------
|
||||
|
||||
_SPECIAL_USE_FLAGS = {
|
||||
"\\sent": "sent",
|
||||
"\\drafts": "drafts",
|
||||
"\\trash": "trash",
|
||||
"\\junk": "junk",
|
||||
"\\archive": "archive",
|
||||
"\\all": "archive", # Gmail "All Mail" acts as archive
|
||||
}
|
||||
|
||||
_LIST_RE = re.compile(rb'^\(([^)]*)\)\s+(?:"([^"]*)"|NIL)\s+(.+)$')
|
||||
_UID_RE = re.compile(rb"UID\s+(\d+)")
|
||||
_FLAGS_RE = re.compile(rb"FLAGS\s+\(([^)]*)\)")
|
||||
_INTERNALDATE_RE = re.compile(rb'INTERNALDATE\s+"([^"]+)"')
|
||||
_LITERAL_RE = re.compile(rb"\{(\d+)\}\s*$")
|
||||
_FETCH_HEADER_RE = re.compile(rb"^\d+\s+FETCH\s+\(", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedMailbox:
|
||||
path: str
|
||||
special_use: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedFetch:
|
||||
uid: int
|
||||
flags: frozenset[str]
|
||||
internal_date: datetime | None
|
||||
raw: bytes | None # None when the fetch did not request the body
|
||||
|
||||
|
||||
def _decode_mailbox_name(raw: bytes) -> str:
|
||||
name = raw.strip()
|
||||
if name.startswith(b'"') and name.endswith(b'"'):
|
||||
name = name[1:-1]
|
||||
# IMAP mailbox names are modified UTF-7 (SPEC §4: store UTF-7 decoded).
|
||||
return str(imap_utf7_decode(bytes(name)))
|
||||
|
||||
|
||||
def parse_list_line(line: bytes) -> ParsedMailbox | None:
|
||||
"""Parse one LIST response line into a :class:`ParsedMailbox` (or None)."""
|
||||
m = _LIST_RE.match(line.strip())
|
||||
if not m:
|
||||
return None
|
||||
flags = m.group(1).lower().split()
|
||||
name = _decode_mailbox_name(m.group(3))
|
||||
special: str | None = None
|
||||
for flag in flags:
|
||||
mapped = _SPECIAL_USE_FLAGS.get(flag.decode("ascii", "ignore"))
|
||||
if mapped:
|
||||
special = mapped
|
||||
break
|
||||
if special is None and name.upper() == "INBOX":
|
||||
special = "inbox"
|
||||
return ParsedMailbox(path=name, special_use=special)
|
||||
|
||||
|
||||
def parse_internaldate(raw: bytes | str) -> datetime | None:
|
||||
"""Parse an IMAP INTERNALDATE (``d-Mon-Y H:M:S +ZZZZ``) to an aware datetime."""
|
||||
text = raw.decode("ascii", "ignore") if isinstance(raw, bytes) else raw
|
||||
text = text.strip()
|
||||
try:
|
||||
return datetime.strptime(text, "%d-%b-%Y %H:%M:%S %z")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_flag_token(tok: bytes) -> str:
|
||||
return tok.decode("ascii", "ignore")
|
||||
|
||||
|
||||
def parse_fetch_header(line: bytes) -> tuple[int | None, frozenset[str], datetime | None]:
|
||||
"""Extract (uid, flags, internaldate) from a FETCH header line."""
|
||||
uid_m = _UID_RE.search(line)
|
||||
uid = int(uid_m.group(1)) if uid_m else None
|
||||
flags_m = _FLAGS_RE.search(line)
|
||||
if flags_m:
|
||||
flags = frozenset(_parse_flag_token(t) for t in flags_m.group(1).split())
|
||||
else:
|
||||
flags = frozenset()
|
||||
date_m = _INTERNALDATE_RE.search(line)
|
||||
internal = parse_internaldate(date_m.group(1)) if date_m else None
|
||||
return uid, flags, internal
|
||||
|
||||
|
||||
def parse_fetch_lines(lines: list[bytes | bytearray]) -> list[ParsedFetch]:
|
||||
"""Walk a full FETCH response, returning one :class:`ParsedFetch` per message.
|
||||
|
||||
Handles both metadata-only fetches (FLAGS/INTERNALDATE) and body fetches
|
||||
(a ``{size}`` literal announced on the header line, body on the next line).
|
||||
"""
|
||||
results: list[ParsedFetch] = []
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
if not isinstance(line, (bytes, bytearray)) or not _FETCH_HEADER_RE.match(bytes(line)):
|
||||
i += 1
|
||||
continue
|
||||
header = bytes(line)
|
||||
uid, flags, internal = parse_fetch_header(header)
|
||||
raw: bytes | None = None
|
||||
if _LITERAL_RE.search(header) and i + 1 < n:
|
||||
body = lines[i + 1]
|
||||
raw = bytes(body)
|
||||
i += 2
|
||||
# Skip the trailing ")" continuation line if present.
|
||||
if (
|
||||
i < n
|
||||
and isinstance(lines[i], (bytes, bytearray))
|
||||
and bytes(lines[i]).strip() == b")"
|
||||
):
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
if uid is not None:
|
||||
results.append(ParsedFetch(uid=uid, flags=flags, internal_date=internal, raw=raw))
|
||||
return results
|
||||
|
||||
|
||||
def parse_search_uids(lines: list[bytes | bytearray]) -> list[int]:
|
||||
"""Parse a UID SEARCH response's first line into a list of ints."""
|
||||
if not lines:
|
||||
return []
|
||||
first = lines[0]
|
||||
if not isinstance(first, (bytes, bytearray)):
|
||||
return []
|
||||
return [int(tok) for tok in bytes(first).split() if tok.isdigit()]
|
||||
|
||||
|
||||
def parse_select_untagged(lines: list[bytes | bytearray]) -> dict[str, int]:
|
||||
"""Extract UIDVALIDITY / UIDNEXT / HIGHESTMODSEQ / EXISTS from SELECT lines."""
|
||||
out: dict[str, int] = {}
|
||||
patterns = {
|
||||
"uidvalidity": re.compile(rb"UIDVALIDITY\s+(\d+)"),
|
||||
"uidnext": re.compile(rb"UIDNEXT\s+(\d+)"),
|
||||
"highestmodseq": re.compile(rb"HIGHESTMODSEQ\s+(\d+)"),
|
||||
"exists": re.compile(rb"^(\d+)\s+EXISTS"),
|
||||
}
|
||||
for line in lines:
|
||||
if not isinstance(line, (bytes, bytearray)):
|
||||
continue
|
||||
b = bytes(line)
|
||||
for key, pat in patterns.items():
|
||||
m = pat.search(b)
|
||||
if m:
|
||||
out[key] = int(m.group(1))
|
||||
return out
|
||||
Reference in New Issue
Block a user