From a44bf11be6e59613f778f9428f6f6c987746f36e Mon Sep 17 00:00:00 2001 From: disqualifier Date: Mon, 29 Jun 2026 17:57:37 -0400 Subject: [PATCH] fix: dead is_throttled, orphan connect-task, server-defined folder delimiter (v0.1.4) - remove is_throttled(): read a non-existent .resp -> always False (dead) (L2) - cancel/await aioimaplib's fire-and-forget create_connection task on a failed connect so a refused host doesn't log 'Task exception was never retrieved' per retry (L3) - get_folders() parses the server-announced LIST delimiter instead of hardcoding '/', so '.'/NIL-delimited servers (Gmail/Dovecot) return correct names (L4) - mark the dead aioimaplib-2.0.x tuple branch + the non-aioimaplib authenticate fallback as cross-version escape hatches (nits). Signed-off-by: disqualifier --- README.md | 8 +++--- pyproject.toml | 2 +- src/aiomail/__init__.py | 2 +- src/aiomail/auth.py | 3 +++ src/aiomail/client.py | 56 +++++++++++++++++++++++++++++++---------- 5 files changed, 52 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 48bf2fd..43ad02a 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ This reads codes from email; it does not generate them (that is `pyotp`'s job). `requirements.txt`: ``` -aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.3 +aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.4 # OAuth token providers (Microsoft / Google) need the extra: -aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.3 +aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.4 ``` Direct: ```bash -pip install "aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.3" -pip install "aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.3" +pip install "aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.4" +pip install "aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.4" ``` Requires `aioimaplib` and `beautifulsoup4` (pulled transitively). The `oauth` diff --git a/pyproject.toml b/pyproject.toml index 504d55d..c96174c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aiomail" -version = "0.1.3" +version = "0.1.4" description = "async IMAP one-time-code retrieval with password/OAuth2 auth and dynamic matching" requires-python = ">=3.10" dependencies = [ diff --git a/src/aiomail/__init__.py b/src/aiomail/__init__.py index 32ade03..61216d9 100644 --- a/src/aiomail/__init__.py +++ b/src/aiomail/__init__.py @@ -29,4 +29,4 @@ __all__ = [ "DEFAULT_FOLDERS", ] -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/src/aiomail/auth.py b/src/aiomail/auth.py index f968bab..ef3312a 100644 --- a/src/aiomail/auth.py +++ b/src/aiomail/auth.py @@ -94,6 +94,9 @@ class OAuth2Auth: if xoauth2 is not None: result, data = await xoauth2(self.user, token) elif hasattr(mail, "authenticate"): + # escape hatch for a non-aioimaplib client: the shipped aioimaplib IMAP4 + # always has .xoauth2 and never .authenticate, so this branch never runs + # for it; the SASL-callback signature here is untested against any driver result, data = await mail.authenticate( "XOAUTH2", lambda _: _sasl_xoauth2(self.user, token) ) diff --git a/src/aiomail/client.py b/src/aiomail/client.py index 428cdae..05222c2 100644 --- a/src/aiomail/client.py +++ b/src/aiomail/client.py @@ -9,6 +9,7 @@ import asyncio import email import email.message import logging +import re from typing import List, Optional from aioimaplib import IMAP4, IMAP4_SSL @@ -17,6 +18,22 @@ from .auth import Auth log = logging.getLogger(__name__) +# IMAP LIST reply: (flags) "" — delim is server-defined (often "/" or +# "." or NIL); capture the trailing name regardless, quoted or bare +_LIST_RE = re.compile(rb'^\([^)]*\)\s+(?:"[^"]*"|NIL)\s+(.+)$') + + +def _folder_name(raw: bytes) -> str: + """extract the folder name from a LIST reply line, delimiter-agnostic + + parses the real reply form `(flags) "" ` so any server hierarchy + delimiter works (not just "/"); falls back to the last quoted/space token if the + line doesn't match the canonical shape. + """ + match = _LIST_RE.match(raw.strip()) + name = match.group(1).decode() if match else raw.decode().rsplit(" ", 1)[-1] + return name.strip().strip('"') + class IMAPClient: """connection-managing IMAP client driven by an injected auth mechanism @@ -68,14 +85,33 @@ class IMAPClient: except Exception as exc: log.warning("connect attempt %d/%d failed: %s", attempt + 1, self.max_retries, exc) if self._mail is not None: - try: - await self._mail.logout() - except Exception as teardown: - log.debug("logout error ignored during failed connect: %s", teardown) + await self._discard_mail(self._mail) self._mail = None await asyncio.sleep(2 * (attempt + 1)) return False + @staticmethod + async def _discard_mail(mail) -> None: + """tear down a half-built IMAP4 without leaking its connect task + + aioimaplib's IMAP4 schedules `create_connection` as a fire-and-forget task it + never retrieves; on a refused connection that task raises and asyncio logs a + noisy "Task exception was never retrieved" traceback. cancel/await it here (and + retrieve its exception) before discarding, so a failed connect stays quiet. + """ + task = getattr(mail, "_client_task", None) + if task is not None and not task.done(): + task.cancel() + if task is not None: + try: + await task + except (asyncio.CancelledError, Exception): + pass + try: + await mail.logout() + except Exception as teardown: + log.debug("logout error ignored during failed connect: %s", teardown) + async def close(self) -> None: """log out and drop the connection, swallowing teardown errors""" if self._mail is not None: @@ -95,14 +131,6 @@ class IMAPClient: except Exception: return await self.connect() - def is_throttled(self) -> bool: - """best-effort detection of a provider throttling response""" - return bool( - self._mail is not None - and getattr(self._mail, "resp", None) - and "THROTTLED" in str(self._mail.resp) - ) - async def get_folders(self) -> List[str]: """list mailbox folder names""" if not await self.ensure_connection(): @@ -115,7 +143,7 @@ class IMAPClient: folders: List[str] = [] for folder in folder_list or []: try: - folders.append(folder.decode().split(' "/" ')[-1].strip('"')) + folders.append(_folder_name(folder)) except Exception: continue return folders @@ -175,6 +203,8 @@ class IMAPClient: # mismatches the header line for any 2+ digit id or a BODY[]/UID fetch. if isinstance(item, bytearray): return email.message_from_bytes(bytes(item)) + # cross-version fallback: aioimaplib 2.0.x never yields tuples here, but an + # imaplib-style (header, payload) tuple is handled if a future/alt driver does if isinstance(item, tuple) and len(item) > 1: return email.message_from_bytes(item[1]) return None