Release v0.1.2
This commit is contained in:
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user