9 Commits
Author SHA1 Message Date
dsql 4984dae9c4 release: 1.0.0
first stable release. pre-1.0.0 verification complete: all surviving MED regressions and
gaps resolved and independently re-fired, tree audited clean across the suite.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 21:21:00 -04:00
dsql 704e7e3939 docs: log a skipped unparseable folder entry instead of silently swallowing
get_folders' per-entry except swallowed a _folder_name failure with no log line, unlike
every sibling handler in the file - add a log.debug so a dropped folder is visible.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 19:37:16 -04:00
dsql 27d37e8bbb fix: tolerate a malformed encoded-word subject; materialize lengths once
decode_header_value's except tuple missed email.errors.HeaderParseError, so a malformed
base64 encoded-word subject (=?utf-8?B?A?=) crashed the OTP scan instead of falling back
to raw - now caught alongside the other decode errors. retrieve_otp consumed lengths once
per fetched message via set(lengths), so a generator was exhausted after the first message
and a later message's fallback code was missed - list() it once up front like folders.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 19:24:57 -04:00
dsql 9c96fa793a fix: select() on the requested folder despite a failed old-folder reselect
_connect_and_reselect_locked() re-selects the prior _selected_folder after
reconnecting and returns False if that reselect fails, even when the underlying
link came back live. select() gated entirely on that bool, so it returned False
without ever sending a SELECT for the folder the caller actually asked for -
retrieve_otp's per-folder loop then silently skips a folder a live connection
could have selected, for that pass. Let select() proceed to its own SELECT
whenever the connection is live (self._mail is not None), only bailing out when
ensure_connection reflects an unreconnectable link.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:15:55 -04:00
dsql 42a1f240f7 fix: bound mark_seen's UID STORE with the configured timeout
aioimaplib forwards a timeout into IMAP4.uid(...) but drops it specifically for
the STORE command (protocol.uid() calls self.store(*criteria, by_uid=True)
without passing timeout through, so the Command never arms its internal timer).
Combined with IMAP4_SSL's default conn_lost_cb=None, a connection that goes
silent during a use_uid=True mark_seen call can hang the coroutine forever,
unlike the non-uid store path which is already wrapped by aioimaplib itself.
Wrap the uid-store call in asyncio.wait_for(self.timeout) so a stalled server
times out and mark_seen returns False like the rest of this method's contract.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:15:39 -04:00
dsql 5f23abc9c7 fix: tolerate NUL-bearing charset in part decode
a MIME part charset param containing a NUL character (e.g. malformed/adversarial
.eml input) makes codec lookup raise ValueError instead of the LookupError/TypeError
already handled here, escaping uncaught through extract_code. Catch ValueError too
and fall back to utf-8 like the existing bad-charset path, mirroring
decode_header_value's existing (UnicodeDecodeError, LookupError, ValueError) pattern.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:15:05 -04:00
dsql 0d88764510 refactor: derive __version__ from package metadata (single source)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:59:12 -04:00
dsql 0038f03b9e docs: expand module contract, compress private docstrings
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:47:32 -04:00
dsql f1e52ff1ac docs: compress prose/module docstrings, em-dash->hyphen (de-bloat wave 1)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 00:12:22 -04:00
8 changed files with 68 additions and 89 deletions
+5 -5
View File
@@ -11,22 +11,22 @@ This reads codes from email; it does not generate them (that is `pyotp`'s job).
`requirements.txt`: `requirements.txt`:
``` ```
aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.8 aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.10
# OAuth token providers (Microsoft / Google) need the extra: # OAuth token providers (Microsoft / Google) need the extra:
aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.8 aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.10
``` ```
Direct: Direct:
```bash ```bash
pip install "aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.8" pip install "aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.10"
pip install "aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.8" pip install "aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.10"
``` ```
Requires `aioimaplib` and `beautifulsoup4` (pulled transitively). The `oauth` Requires `aioimaplib` and `beautifulsoup4` (pulled transitively). The `oauth`
extra adds `aiohttp` for the refresh-token providers. extra adds `aiohttp` for the refresh-token providers.
Drop the `@v0.1.8` suffix from the line above to install the latest unpinned. Drop the `@v0.1.10` suffix from the line above to install the latest unpinned.
## Password auth ## Password auth
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "aiomail" name = "aiomail"
version = "0.1.8" version = "1.0.0"
description = "async IMAP one-time-code retrieval with password/OAuth2 auth and dynamic matching" description = "async IMAP one-time-code retrieval with password/OAuth2 auth and dynamic matching"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+14 -5
View File
@@ -1,9 +1,15 @@
"""aiomail async IMAP one-time-code retrieval. """aiomail - async IMAP one-time-code retrieval, password or OAuth2 auth, dynamic matching.
reads OTP / login codes out of IMAP mailboxes (accounts you own). supports plain facade over auth (PasswordAuth/OAuth2Auth), IMAPClient (connection lifecycle), and
password and OAuth2 (XOAUTH2) auth, and dynamic sender/subject/code matching via retrieve_otp (folder-scan orchestration); see each module's docstring for detail.
substrings, regexes, or callables.
footguns: an IMAPClient instance is not safe for concurrent callers beyond its internal
connect/reconnect lock; sequence-number ids from before a reconnect are invalid after
(pass use_uid=True if ids must survive one); credentials are always caller-supplied via
an injected Auth, never read from config.
""" """
from importlib.metadata import version, PackageNotFoundError
from .auth import Auth, OAuth2Auth, PasswordAuth from .auth import Auth, OAuth2Auth, PasswordAuth
from .client import IMAPClient from .client import IMAPClient
from .extract import ( from .extract import (
@@ -31,4 +37,7 @@ __all__ = [
"DEFAULT_FOLDERS", "DEFAULT_FOLDERS",
] ]
__version__ = "0.1.8" try:
__version__ = version("aiomail")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
+5 -10
View File
@@ -1,15 +1,11 @@
"""authentication mechanisms for the IMAP client. """authentication mechanisms for the IMAP client: `PasswordAuth` (LOGIN), `OAuth2Auth` (XOAUTH2)."""
`PasswordAuth` (LOGIN) and `OAuth2Auth` (XOAUTH2); credentials always injected.
"""
import base64 import base64
import logging import logging
from typing import Awaitable, Callable, Optional, Protocol, Union, runtime_checkable from typing import Awaitable, Callable, Optional, Protocol, Union, runtime_checkable
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# a token provider is any (optionally async) callable returning a fresh access # see aiomail.oauth for ready-made Microsoft / Google providers
# token string; see aiomail.oauth for ready-made Microsoft / Google providers
TokenProvider = Callable[[], Union[str, Awaitable[str]]] TokenProvider = Callable[[], Union[str, Awaitable[str]]]
@@ -36,7 +32,7 @@ class PasswordAuth:
def _as_str(token) -> str: def _as_str(token) -> str:
"""coerce a token to str (a provider may hand back bytes); both XOAUTH2 entrypoints downstream need str""" """coerce a token to str (a provider may hand back bytes)"""
return token.decode() if isinstance(token, bytes) else token return token.decode() if isinstance(token, bytes) else token
@@ -73,9 +69,8 @@ class OAuth2Auth:
async def authenticate(self, mail) -> None: async def authenticate(self, mail) -> None:
token = await self._resolve_token() token = await self._resolve_token()
# mail.xoauth2(user, token) f-string-interpolates token, so it MUST be str # mail.xoauth2(user, token) f-string-interpolates token, so it MUST be str -
# bytes would interpolate the b'...' repr and corrupt the Bearer value. # bytes would interpolate the b'...' repr and corrupt the Bearer value.
# _resolve_token already guarantees str via _as_str.
xoauth2 = getattr(mail, "xoauth2", None) xoauth2 = getattr(mail, "xoauth2", None)
if xoauth2 is not None: if xoauth2 is not None:
result, data = await xoauth2(self.user, token) result, data = await xoauth2(self.user, token)
+29 -38
View File
@@ -1,9 +1,9 @@
"""async IMAP client wrapping aioimaplib: connect/retry/reconnect/close plus folders/search/fetch/mark-seen. """async IMAP client wrapping aioimaplib: connect/retry/reconnect/close plus folders/search/fetch/mark-seen.
auth is injected. reconnect-on-stale re-selects the prior folder, but sequence-number ids from auth is injected. reconnect-on-stale re-selects the prior folder, but sequence-number ids from
before a reconnect are not valid after (a fresh SELECT can renumber the mailbox) before a reconnect are not valid after (a fresh SELECT can renumber the mailbox) - pass
— pass `use_uid=True` if ids need to survive a reconnect. one instance is not `use_uid=True` if ids need to survive a reconnect. one instance is not safe for concurrent
safe for concurrent callers beyond the internal connect/reconnect lock. callers beyond the internal connect/reconnect lock.
""" """
import asyncio import asyncio
import email import email
@@ -18,18 +18,13 @@ from .auth import Auth
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# IMAP LIST reply: (flags) "<delim>" <name> delim is server-defined (often "/" or # IMAP LIST reply: (flags) "<delim>" <name> - delim is server-defined (often "/" or
# "." or NIL); capture the trailing name regardless, quoted or bare # "." or NIL); capture the trailing name regardless, quoted or bare
_LIST_RE = re.compile(rb'^\([^)]*\)\s+(?:"[^"]*"|NIL)\s+(.+)$') _LIST_RE = re.compile(rb'^\([^)]*\)\s+(?:"[^"]*"|NIL)\s+(.+)$')
def _folder_name(raw: bytes) -> Optional[str]: def _folder_name(raw: bytes) -> Optional[str]:
"""extract the folder name from a LIST reply line, delimiter-agnostic """extract the folder name from a LIST reply line, or None on no match"""
returns None on a non-matching line instead of a last-token rsplit fallback,
so the tagged completion line aioimaplib appends to the same response list
(e.g. `b"LIST completed."`) gets dropped instead of read as a phantom folder.
"""
match = _LIST_RE.match(raw.strip()) match = _LIST_RE.match(raw.strip())
if not match: if not match:
return None return None
@@ -39,8 +34,7 @@ def _folder_name(raw: bytes) -> Optional[str]:
class IMAPClient: class IMAPClient:
"""connection-managing IMAP client driven by an injected auth mechanism """connection-managing IMAP client driven by an injected auth mechanism
`use_uid` (UID vs sequence-number addressing) is independent of `use_ssl` `use_uid` (UID vs sequence-number addressing) is independent of `use_ssl` - unrelated concerns.
an earlier draft conflated them (`use_uid = use_ssl`); unrelated concerns.
""" """
def __init__( def __init__(
@@ -73,11 +67,7 @@ class IMAPClient:
await self.close() await self.close()
async def connect(self) -> bool: async def connect(self) -> bool:
"""open a connection and authenticate, retrying with linear backoff """open a connection and authenticate, retrying with linear backoff; serialized by an internal lock"""
serialized by an internal lock: queued callers never tear down each
other's in-progress handshake, and a superseded connection is logged out.
"""
async with self._lock: async with self._lock:
return await self._connect_locked() return await self._connect_locked()
@@ -107,19 +97,13 @@ class IMAPClient:
@staticmethod @staticmethod
async def _discard_mail(mail) -> None: async def _discard_mail(mail) -> None:
"""tear down a half-built IMAP4 without leaking its connect task """tear down a half-built IMAP4 without leaking its fire-and-forget connect task"""
aioimaplib schedules `create_connection` as a fire-and-forget task; on a
refused connection it raises and asyncio logs a noisy "Task exception was
never retrieved" traceback unless retrieved here first.
"""
task = getattr(mail, "_client_task", None) task = getattr(mail, "_client_task", None)
if task is not None and not task.done(): if task is not None and not task.done():
task.cancel() task.cancel()
if task is not None: if task is not None:
# shield distinguishes our own task.cancel() from an external cancel of # shield: a bare `await task` would also swallow an external cancel; under
# this coroutine: a bare `await task` swallowed both, resisting # shield, CancelledError here means external only.
# cancellation. under shield, CancelledError here means external only.
try: try:
await asyncio.shield(task) await asyncio.shield(task)
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -148,13 +132,8 @@ class IMAPClient:
self._selected_folder = None self._selected_folder = None
async def ensure_connection(self) -> bool: async def ensure_connection(self) -> bool:
"""return a live, SELECTED-if-applicable connection, reconnecting if the link is stale """return a live, SELECTED-if-applicable connection, reconnecting (and re-selecting the prior
folder) if the link is stale; see module docstring for the sequence-number-vs-use_uid caveat"""
re-selects the previously-selected folder after a reconnect; sequence-number
ids from before the reconnect are NOT valid against the new session (a fresh
SELECT can renumber the mailbox) unless use_uid=True. serialized by the
internal lock so a queued caller rechecks liveness before reconnecting.
"""
async with self._lock: async with self._lock:
if self._mail is not None: if self._mail is not None:
try: try:
@@ -197,15 +176,21 @@ class IMAPClient:
for folder in folder_list or []: for folder in folder_list or []:
try: try:
name = _folder_name(folder) name = _folder_name(folder)
except Exception: except Exception as exc:
log.debug("skipping unparseable folder entry %r: %s", folder, exc)
continue continue
if name is not None: if name is not None:
folders.append(name) folders.append(name)
return folders return folders
async def select(self, folder: str) -> bool: async def select(self, folder: str) -> bool:
"""select a folder, returning whether it succeeded""" """select a folder, returning whether it succeeded
if not await self.ensure_connection():
connects/reconnects first if needed; a failed re-select of the *previously*
selected folder during reconnect does not block attempting this call's own
target folder, since a live connection can still select it.
"""
if not await self.ensure_connection() and self._mail is None:
return False return False
try: try:
result, _ = await self._mail.select(f'"{folder}"') result, _ = await self._mail.select(f'"{folder}"')
@@ -266,12 +251,18 @@ class IMAPClient:
return None return None
async def mark_seen(self, email_id: int) -> bool: async def mark_seen(self, email_id: int) -> bool:
"""flag a message as read without deleting it""" """flag a message as read without deleting it
aioimaplib does not apply its own timeout to the UID STORE command path, so the
use_uid=True call is wrapped here to bound it the same as the non-uid path.
"""
if not await self.ensure_connection(): if not await self.ensure_connection():
return False return False
try: try:
if self.use_uid: if self.use_uid:
result, _ = await self._mail.uid("store", str(email_id), "+FLAGS", "(\\Seen)") result, _ = await asyncio.wait_for(
self._mail.uid("store", str(email_id), "+FLAGS", "(\\Seen)"), self.timeout
)
else: else:
result, _ = await self._mail.store(str(email_id), "+FLAGS", "(\\Seen)") result, _ = await self._mail.store(str(email_id), "+FLAGS", "(\\Seen)")
return result == "OK" return result == "OK"
+6 -11
View File
@@ -1,12 +1,8 @@
"""code extraction and dynamic matching for email messages, pure logic with no network IO. """code extraction and dynamic matching for email messages, pure logic with no network IO."""
`extract_code` pulls a one-time code out of a message; `as_predicate` turns a
string / compiled regex / callable into a uniform match function for filtering
senders and subjects.
"""
import email.message import email.message
import logging import logging
import re import re
from email.errors import HeaderParseError
from email.header import decode_header, make_header from email.header import decode_header, make_header
from typing import Callable, Iterable, Iterator, Optional, Pattern, Sequence, Union from typing import Callable, Iterable, Iterator, Optional, Pattern, Sequence, Union
@@ -49,7 +45,7 @@ def decode_header_value(raw: str) -> str:
return raw return raw
try: try:
return str(make_header(decode_header(raw))) return str(make_header(decode_header(raw)))
except (UnicodeDecodeError, LookupError, ValueError) as exc: except (UnicodeDecodeError, LookupError, ValueError, HeaderParseError) as exc:
log.debug("header decode failed (%s): %s", raw, exc) log.debug("header decode failed (%s): %s", raw, exc)
return raw return raw
@@ -62,7 +58,7 @@ def _decode_part(part: email.message.Message) -> Optional[str]:
charset = part.get_content_charset() or "utf-8" charset = part.get_content_charset() or "utf-8"
try: try:
return payload.decode(charset, errors="replace") return payload.decode(charset, errors="replace")
except (LookupError, TypeError): except (LookupError, TypeError, ValueError):
return payload.decode("utf-8", errors="replace") return payload.decode("utf-8", errors="replace")
@@ -103,9 +99,8 @@ def extract_code(
) -> Optional[str]: ) -> Optional[str]:
"""extract a one-time code from a message, subject first then body parts """extract a one-time code from a message, subject first then body parts
`patterns` are regexes tried in order (first capturing group wins, else the `patterns` are regexes tried in order (first capturing group wins, else the whole
whole match); if none hit, a standalone digit run whose length is in match); if none hit, a standalone digit run whose length is in `lengths` is returned.
`lengths` is returned. both are parameters so callers tune per provider.
""" """
compiled = _compile(patterns) compiled = _compile(patterns)
length_set = set(lengths) length_set = set(lengths)
+5 -8
View File
@@ -1,8 +1,6 @@
"""optional OAuth2 token providers (refresh-token -> access-token) for `OAuth2Auth`, credentials always caller-supplied. """optional OAuth2 token providers (refresh-token -> access-token) for `OAuth2Auth`, credentials always
caller-supplied. aiohttp is an optional extra; missing it raises a clear error only when a provider is
aiohttp is an optional extra so the core stays light; missing it raises a clear instantiated, not on import."""
error only when a provider is instantiated, not on import.
"""
import asyncio import asyncio
import logging import logging
import time import time
@@ -80,14 +78,13 @@ class _RefreshTokenProvider:
if token: if token:
self._failures = 0 self._failures = 0
return token return token
# log a truncated body (never whole, may carry sensitive # truncated, never whole: the body may carry sensitive material
# material) so a 200-with-no-token isn't a silent drop
log.warning( log.warning(
"token endpoint %s -> 200 with no access_token: %s", "token endpoint %s -> 200 with no access_token: %s",
endpoint, str(body_json)[:200], endpoint, str(body_json)[:200],
) )
else: else:
# truncated only the body may carry sensitive material # truncated only - the body may carry sensitive material
body = (await resp.text())[:200] body = (await resp.text())[:200]
log.warning("token endpoint %s -> %s: %s", endpoint, resp.status, body) log.warning("token endpoint %s -> %s: %s", endpoint, resp.status, body)
except Exception as exc: except Exception as exc:
+3 -11
View File
@@ -1,8 +1,4 @@
"""orchestration: `retrieve_otp` ties the client and extractor together to find the most recent valid OTP. """orchestration: `retrieve_otp` ties the client and extractor together to find the most recent valid OTP."""
sender/subject accept the flexible match specs from `extract`; provider quirks
(folders, age, patterns) live in the arguments, not hardcoded branches.
"""
import asyncio import asyncio
import logging import logging
import time import time
@@ -27,12 +23,7 @@ DEFAULT_FOLDERS: Sequence[str] = ("INBOX", "Junk", "Spam", "Archive", "All Mail"
def _server_query(sender: MatchSpec, subject: MatchSpec, match_field: str = "from") -> str: def _server_query(sender: MatchSpec, subject: MatchSpec, match_field: str = "from") -> str:
"""build a narrowing IMAP query from plain-string specs only, falling back to ALL for regex/callable specs """build a narrowing IMAP query from plain-string specs, falling back to ALL for regex/callable specs"""
`match_field="to"` searches TO OR FROM (a forwarded code may keep the
original From), matching the client-side forwarded-From fallback so the
server query never narrows out a result the client would have accepted.
"""
parts: List[str] = [] parts: List[str] = []
if isinstance(sender, str): if isinstance(sender, str):
if match_field == "to": if match_field == "to":
@@ -84,6 +75,7 @@ async def retrieve_otp(
forwarded match on From. set `max_age=None` to disable the freshness check. forwarded match on From. set `max_age=None` to disable the freshness check.
""" """
folders = list(folders) if folders is not None else list(DEFAULT_FOLDERS) folders = list(folders) if folders is not None else list(DEFAULT_FOLDERS)
lengths = list(lengths)
sender_ok = as_predicate(sender) sender_ok = as_predicate(sender)
subject_ok = as_predicate(subject) subject_ok = as_predicate(subject)
query = _server_query(sender, subject, match_field) query = _server_query(sender, subject, match_field)