11 Commits
Author SHA1 Message Date
dsql e62a2db1aa chore: bump to 1.1.0 (logging-discipline audit)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-10 23:00:50 -04:00
dsql 1f24198121 fix: log terminal connect exhaustion so a dead connection isn't silent
_connect_locked logged a per-attempt WARNING but returned False on exhaustion with no
terminal line - the per-attempt lines were the only trace, so a genuinely dead IMAP
connection could read as routine retry noise with no loud terminal signal (rule-3 guard:
a swallow-to-default must carry a terminal log, and demoting/relying only on attempt lines
would leave it silent). add a terminal WARNING before the False return; keep the
per-attempt WARNING. host only (no creds) in the message.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-09 02:13:44 -04:00
dsql d827618075 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-09 18:53:15 -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 73 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`:
```
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@v1.0.0
# 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@v1.0.0
```
Direct:
```bash
pip install "aiomail @ 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.8"
pip install "aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v1.0.0"
pip install "aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v1.0.0"
```
Requires `aioimaplib` and `beautifulsoup4` (pulled transitively). The `oauth`
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 `@v1.0.0` suffix from the line above to install the latest unpinned.
## Password auth
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aiomail"
version = "0.1.8"
version = "1.1.0"
description = "async IMAP one-time-code retrieval with password/OAuth2 auth and dynamic matching"
requires-python = ">=3.10"
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
password and OAuth2 (XOAUTH2) auth, and dynamic sender/subject/code matching via
substrings, regexes, or callables.
facade over auth (PasswordAuth/OAuth2Auth), IMAPClient (connection lifecycle), and
retrieve_otp (folder-scan orchestration); see each module's docstring for detail.
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 .client import IMAPClient
from .extract import (
@@ -31,4 +37,7 @@ __all__ = [
"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.
`PasswordAuth` (LOGIN) and `OAuth2Auth` (XOAUTH2); credentials always injected.
"""
"""authentication mechanisms for the IMAP client: `PasswordAuth` (LOGIN), `OAuth2Auth` (XOAUTH2)."""
import base64
import logging
from typing import Awaitable, Callable, Optional, Protocol, Union, runtime_checkable
log = logging.getLogger(__name__)
# a token provider is any (optionally async) callable returning a fresh access
# token string; see aiomail.oauth for ready-made Microsoft / Google providers
# see aiomail.oauth for ready-made Microsoft / Google providers
TokenProvider = Callable[[], Union[str, Awaitable[str]]]
@@ -36,7 +32,7 @@ class PasswordAuth:
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
@@ -73,9 +69,8 @@ class OAuth2Auth:
async def authenticate(self, mail) -> None:
token = await self._resolve_token()
# mail.xoauth2(user, token) f-string-interpolates token, so it MUST be str
# bytes would interpolate the b'...' repr and corrupt the Bearer value.
# _resolve_token already guarantees str via _as_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.
xoauth2 = getattr(mail, "xoauth2", None)
if xoauth2 is not None:
result, data = await xoauth2(self.user, token)
+34 -38
View File
@@ -1,9 +1,9 @@
"""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
before a reconnect are not valid after (a fresh SELECT can renumber the mailbox)
— pass `use_uid=True` if ids need to survive a reconnect. one instance is not
safe for concurrent callers beyond the internal connect/reconnect lock.
before a reconnect are not valid after (a fresh SELECT can renumber the mailbox) - pass
`use_uid=True` if ids need to survive a reconnect. one instance is not safe for concurrent
callers beyond the internal connect/reconnect lock.
"""
import asyncio
import email
@@ -18,18 +18,13 @@ from .auth import Auth
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
_LIST_RE = re.compile(rb'^\([^)]*\)\s+(?:"[^"]*"|NIL)\s+(.+)$')
def _folder_name(raw: bytes) -> Optional[str]:
"""extract the folder name from a LIST reply line, delimiter-agnostic
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.
"""
"""extract the folder name from a LIST reply line, or None on no match"""
match = _LIST_RE.match(raw.strip())
if not match:
return None
@@ -39,8 +34,7 @@ def _folder_name(raw: bytes) -> Optional[str]:
class IMAPClient:
"""connection-managing IMAP client driven by an injected auth mechanism
`use_uid` (UID vs sequence-number addressing) is independent of `use_ssl`
an earlier draft conflated them (`use_uid = use_ssl`); unrelated concerns.
`use_uid` (UID vs sequence-number addressing) is independent of `use_ssl` - unrelated concerns.
"""
def __init__(
@@ -73,11 +67,7 @@ class IMAPClient:
await self.close()
async def connect(self) -> bool:
"""open a connection and authenticate, retrying with linear backoff
serialized by an internal lock: queued callers never tear down each
other's in-progress handshake, and a superseded connection is logged out.
"""
"""open a connection and authenticate, retrying with linear backoff; serialized by an internal lock"""
async with self._lock:
return await self._connect_locked()
@@ -103,23 +93,22 @@ class IMAPClient:
self._mail = None
if attempt < self.max_retries - 1:
await asyncio.sleep(2 * (attempt + 1))
# terminal signal: all attempts are exhausted and this SWALLOWS the failure into a
# False return the caller branches on. without this line the per-attempt WARNINGs are
# the only trace, so a genuinely dead connection could look like routine noise - log
# the terminal exhaustion loudly so real degradation is visible, then return False.
log.warning("connect to %s failed after %d attempts", self.host, self.max_retries)
return False
@staticmethod
async def _discard_mail(mail) -> None:
"""tear down a half-built IMAP4 without leaking its 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.
"""
"""tear down a half-built IMAP4 without leaking its fire-and-forget connect task"""
task = getattr(mail, "_client_task", None)
if task is not None and not task.done():
task.cancel()
if task is not None:
# shield distinguishes our own task.cancel() from an external cancel of
# this coroutine: a bare `await task` swallowed both, resisting
# cancellation. under shield, CancelledError here means external only.
# shield: a bare `await task` would also swallow an external cancel; under
# shield, CancelledError here means external only.
try:
await asyncio.shield(task)
except asyncio.CancelledError:
@@ -148,13 +137,8 @@ class IMAPClient:
self._selected_folder = None
async def ensure_connection(self) -> bool:
"""return a live, SELECTED-if-applicable connection, reconnecting if the link is stale
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.
"""
"""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"""
async with self._lock:
if self._mail is not None:
try:
@@ -197,15 +181,21 @@ class IMAPClient:
for folder in folder_list or []:
try:
name = _folder_name(folder)
except Exception:
except Exception as exc:
log.debug("skipping unparseable folder entry %r: %s", folder, exc)
continue
if name is not None:
folders.append(name)
return folders
async def select(self, folder: str) -> bool:
"""select a folder, returning whether it succeeded"""
if not await self.ensure_connection():
"""select a folder, returning whether it succeeded
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
try:
result, _ = await self._mail.select(f'"{folder}"')
@@ -266,12 +256,18 @@ class IMAPClient:
return None
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():
return False
try:
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:
result, _ = await self._mail.store(str(email_id), "+FLAGS", "(\\Seen)")
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.
`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.
"""
"""code extraction and dynamic matching for email messages, pure logic with no network IO."""
import email.message
import logging
import re
from email.errors import HeaderParseError
from email.header import decode_header, make_header
from typing import Callable, Iterable, Iterator, Optional, Pattern, Sequence, Union
@@ -49,7 +45,7 @@ def decode_header_value(raw: str) -> str:
return raw
try:
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)
return raw
@@ -62,7 +58,7 @@ def _decode_part(part: email.message.Message) -> Optional[str]:
charset = part.get_content_charset() or "utf-8"
try:
return payload.decode(charset, errors="replace")
except (LookupError, TypeError):
except (LookupError, TypeError, ValueError):
return payload.decode("utf-8", errors="replace")
@@ -103,9 +99,8 @@ def extract_code(
) -> Optional[str]:
"""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
whole match); if none hit, a standalone digit run whose length is in
`lengths` is returned. both are parameters so callers tune per provider.
`patterns` are regexes tried in order (first capturing group wins, else the whole
match); if none hit, a standalone digit run whose length is in `lengths` is returned.
"""
compiled = _compile(patterns)
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.
aiohttp is an optional extra so the core stays light; missing it raises a clear
error only when a provider is instantiated, not on import.
"""
"""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
instantiated, not on import."""
import asyncio
import logging
import time
@@ -80,14 +78,13 @@ class _RefreshTokenProvider:
if token:
self._failures = 0
return token
# log a truncated body (never whole, may carry sensitive
# material) so a 200-with-no-token isn't a silent drop
# truncated, never whole: the body may carry sensitive material
log.warning(
"token endpoint %s -> 200 with no access_token: %s",
endpoint, str(body_json)[:200],
)
else:
# truncated only the body may carry sensitive material
# truncated only - the body may carry sensitive material
body = (await resp.text())[:200]
log.warning("token endpoint %s -> %s: %s", endpoint, resp.status, body)
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.
sender/subject accept the flexible match specs from `extract`; provider quirks
(folders, age, patterns) live in the arguments, not hardcoded branches.
"""
"""orchestration: `retrieve_otp` ties the client and extractor together to find the most recent valid OTP."""
import asyncio
import logging
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:
"""build a narrowing IMAP query from plain-string specs only, 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.
"""
"""build a narrowing IMAP query from plain-string specs, falling back to ALL for regex/callable specs"""
parts: List[str] = []
if isinstance(sender, str):
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.
"""
folders = list(folders) if folders is not None else list(DEFAULT_FOLDERS)
lengths = list(lengths)
sender_ok = as_predicate(sender)
subject_ok = as_predicate(subject)
query = _server_query(sender, subject, match_field)