Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 3cd8c45c42
commit 3d82d7b32d
40 changed files with 1877 additions and 520 deletions

View File

@@ -50,6 +50,8 @@ class ImapLoginTestResult:
class ImapMailboxInfo:
name: str
flags: list[str]
message_count: int | None = None
unseen_count: int | None = None
@dataclass(frozen=True, slots=True)
@@ -111,6 +113,8 @@ class ImapMailboxMessageListResult:
folder: str
messages: list[ImapMailboxMessageSummary]
total_count: int
offset: int
limit: int
@dataclass(frozen=True, slots=True)
@@ -133,8 +137,6 @@ class ImapAppendResult:
def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
if not config.enabled:
raise ImapConfigurationError("IMAP is disabled")
if not config.host:
raise ImapConfigurationError("IMAP host is required")
if not config.port:
@@ -311,7 +313,12 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
folders = [ImapMailboxInfo(name=str(item["name"]), flags=list(item.get("flags") or [])) for item in MOCK_IMAP_FOLDERS]
records = list_records(limit=500)
folders = []
for item in MOCK_IMAP_FOLDERS:
name = str(item["name"])
count = sum(1 for record in records if _mock_folder_matches(record, name))
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
return ImapFolderListResult(
host=host,
port=port,
@@ -334,7 +341,8 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
continue
name, flags = extracted
parsed.append((name, flags))
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags)))
message_count, unseen_count = (None, None) if _has_folder_flag(flags, "noselect") else _imap_folder_status(client, name)
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
return ImapFolderListResult(
host=host,
@@ -350,6 +358,31 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
pass
def _has_folder_flag(flags: set[str], flag: str) -> bool:
wanted = flag.casefold().lstrip("\\")
return any(item.casefold().lstrip("\\") == wanted for item in flags)
def _quote_mailbox_name(name: str) -> str:
return "\"" + name.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
def _imap_folder_status(client: imaplib.IMAP4, folder: str) -> tuple[int | None, int | None]:
try:
typ, data = client.status(_quote_mailbox_name(folder), "(MESSAGES UNSEEN)")
except Exception:
return None, None
if typ != "OK":
return None, None
text = " ".join(_decode_item(item) for item in data or [])
messages_match = re.search(r"\bMESSAGES\s+(\d+)", text, re.IGNORECASE)
unseen_match = re.search(r"\bUNSEEN\s+(\d+)", text, re.IGNORECASE)
return (
int(messages_match.group(1)) if messages_match else None,
int(unseen_match.group(1)) if unseen_match else None,
)
def _parse_message(raw: bytes) -> EmailMessage:
return BytesParser(policy=policy.default).parsebytes(raw)
@@ -393,6 +426,24 @@ def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]:
return attachments
def _message_summary_from_headers(*, uid: str, folder: str, headers: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
message = _parse_message(headers.rstrip() + b"\r\n\r\n")
return ImapMailboxMessageSummary(
uid=uid,
folder=folder,
subject=_header_text(message, "Subject"),
from_header=_header_text(message, "From"),
to_header=_header_text(message, "To"),
cc_header=_header_text(message, "Cc"),
date=_header_text(message, "Date"),
message_id=_header_text(message, "Message-ID"),
flags=flags,
size_bytes=size_bytes,
body_preview=None,
attachment_count=0,
)
def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
message = _parse_message(raw)
attachments = _attachment_infos(message)
@@ -434,6 +485,14 @@ def _message_detail_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[s
)
def _parse_fetch_metadata(metadata: str) -> tuple[str | None, list[str], int | None]:
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE)
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
flags = flags_match.group(1).split() if flags_match else []
return uid_match.group(1) if uid_match else None, flags, int(size_match.group(1)) if size_match else None
def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]:
metadata_parts: list[str] = []
raw: bytes | None = None
@@ -445,24 +504,38 @@ def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str
else:
metadata_parts.append(_decode_item(item))
metadata = " ".join(part for part in metadata_parts if part)
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE)
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
flags = flags_match.group(1).split() if flags_match else []
uid, flags, size_bytes = _parse_fetch_metadata(metadata)
if raw is None:
raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True)
return (
uid_match.group(1) if uid_match else None,
flags,
int(size_match.group(1)) if size_match else None,
raw,
)
return uid, flags, size_bytes, raw
def _select_readonly(client: imaplib.IMAP4, folder: str) -> None:
def _parse_fetch_sequence(metadata: str) -> str | None:
match = re.match(r"\s*(\d+)\s+\(", metadata)
return match.group(1) if match else None
def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -> list[tuple[str | None, str | None, list[str], int | None, bytes]]:
parts: list[tuple[str | None, str | None, list[str], int | None, bytes]] = []
for item in data or []:
if not isinstance(item, tuple) or not isinstance(item[1], bytes):
continue
metadata = _decode_item(item[0])
sequence = _parse_fetch_sequence(metadata)
uid, flags, size_bytes = _parse_fetch_metadata(metadata)
parts.append((sequence, uid, flags, size_bytes, item[1]))
return parts
def _select_readonly(client: imaplib.IMAP4, folder: str) -> int:
typ, data = client.select(folder, readonly=True)
if typ != "OK":
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
selected_count = _decode_item(data[0] if data else None).strip()
try:
return max(0, int(selected_count))
except ValueError:
return 0
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
@@ -473,6 +546,50 @@ def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[st
return fetched_uid or str(uid), flags, size_bytes, raw
def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]:
typ, data = client.fetch(str(sequence), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
if typ != "OK":
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
if not fetched_uid:
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
return fetched_uid, flags, size_bytes, raw
def _sequence_set(sequences: list[str]) -> str:
sequence_numbers = sorted({int(sequence) for sequence in sequences if str(sequence).isdigit()})
if not sequence_numbers:
return ",".join(str(sequence) for sequence in sequences)
expected_range = list(range(sequence_numbers[0], sequence_numbers[-1] + 1))
if sequence_numbers == expected_range:
return f"{sequence_numbers[0]}:{sequence_numbers[-1]}"
return ",".join(str(sequence) for sequence in sequence_numbers)
def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not sequences:
return []
typ, data = client.fetch(_sequence_set(sequences), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
if typ != "OK":
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
by_sequence: dict[str, ImapMailboxMessageSummary] = {}
for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
if not sequence or not fetched_uid:
continue
by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
summaries: list[ImapMailboxMessageSummary] = []
for sequence in sequences:
summary = by_sequence.get(str(sequence))
if summary is not None:
summaries.append(summary)
continue
fetched_uid, flags, size_bytes, raw = _fetch_message_by_sequence(client, sequence)
summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
return summaries
def _mock_record_folder(record: dict[str, Any]) -> str:
folder = str(record.get("folder") or "").strip()
if folder:
@@ -495,14 +612,16 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
return "\n".join(lines).encode("utf-8", errors="replace")
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50) -> ImapMailboxMessageListResult:
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0) -> ImapMailboxMessageListResult:
"""List mailbox messages without mutating read/unread state."""
host, port = _require_imap_config(imap_config)
folder = (folder or "INBOX").strip() or "INBOX"
limit = max(1, min(limit, 100))
offset = max(0, offset)
if is_mock_imap_host(imap_config.host):
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
page_records = records[offset:offset + limit]
messages = [
_message_summary_from_raw(
uid=str(record.get("id") or ""),
@@ -511,23 +630,16 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
size_bytes=int(record.get("size_bytes") or 0) or None,
)
for record in records[:limit]
for record in page_records
]
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records))
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records), offset=offset, limit=limit)
client = _open_imap(imap_config)
try:
_select_readonly(client, folder)
typ, data = client.uid("search", None, "ALL")
if typ != "OK":
raise ImapAppendError(f"IMAP search failed for folder {folder!r}: {data!r}", temporary=True)
uid_bytes = data[0] if data else b""
uids = _decode_item(uid_bytes).split()
messages: list[ImapMailboxMessageSummary] = []
for uid in reversed(uids[-limit:]):
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(uids))
total_count = _select_readonly(client, folder)
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit)
finally:
try:
client.logout()
@@ -535,6 +647,14 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
pass
def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]:
if total_count <= 0 or offset >= total_count:
return []
end = total_count - offset
start = max(1, end - limit + 1)
return [str(sequence) for sequence in range(end, start - 1, -1)]
def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult:
"""Fetch one message body using BODY.PEEK in a read-only selected mailbox."""