5 Commits
Author SHA1 Message Date
dsql fd789c2ba2 fix: decode RFC2047 subjects, OR TO/FROM in to-mode query, tz-aware Date, drop LIST completion line (v0.1.7)
Subject headers were matched/scanned raw (never RFC2047-decoded), so any
provider that base64/quoted-printable encodes non-ASCII subjects silently
failed subject filtering and code extraction. match_field='to' only
server-queried TO, so an older TO-matching message permanently hid a
forwarded-From match the client-side check was documented to accept.
_age_seconds treated a naive datetime (from a "-0000" Date header) as local
time instead of UTC, corrupting max_age freshness on non-UTC hosts. get_folders
appended aioimaplib's tagged LIST completion text as a phantom folder name on
every call against any RFC-compliant server.

Also corrects __init__.py's __version__, which was left at 0.1.5 through the
0.1.6 release.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 17:02:56 -04:00
dsql a340067048 fix: reconnect-on-stale re-selects folder; lock connect() against races
ensure_connection() reconnected via connect(), which only reaches IMAP
state AUTH, not SELECTED - every search/fetch/store after a mid-pass
link drop silently failed (swallowed into []/None/False) because
aioimaplib rejects those commands outside SELECTED. Track the
currently-selected folder and re-select it after reconnecting; note
pre-reconnect sequence-number ids are invalid post-reconnect unless
use_uid=True.

Also add an asyncio.Lock around connect()/close()/ensure_connection():
concurrent callers on one instance used to race inside connect(),
where task B's `await self.close()` tore down task A's mid-handshake
connection, leaking sockets and orphaning server sessions. Superseded
connections are now always logged out instead of silently overwritten.

Bump to 0.1.6, update README install pins.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 16:40:50 -04:00
dsql b00f122b74 chore: ignore .claude/ dir (CLAUDE.md now lives under .claude/)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:55:13 -04:00
dsql 3da833f2fc fix: revert OTP-in-logs (spent on arrival, not a secret); F1 ContentTypeError, F5 callable None-guard
revert the M-1 log change — a single-use OTP is consumed on arrival, not a live secret,
so log the code value again. keep the oauth error-body truncate.

F1: oauth token fetch uses resp.json(content_type=None) so a 200 with text/plain doesn't
ContentTypeError and discard a valid token. F5: as_predicate coalesces None for the
callable branch like the string/regex branches. drop a redundant digits.isdigit().

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:34:35 -04:00
dsql f940641a5a fix: never log the OTP code value (secret-in-logs); correct false test claim (v0.1.5)
M-1: retrieve.py logged the live single-use code at INFO ('found code %s', 'code %s
skipped too old'), shipping the secret to any aggregation/retention sink the host wires
(our /srv/logs -> loki/grafana path). drop the code value from both lines — log that a
code was found/retrieved and where, never the value. also truncate the oauth token-endpoint
error body to 200 chars so a token response can't be dumped whole.

aiomail-F3: CLAUDE.md claimed an '8-case tested' suite that does not exist in the repo;
corrected to describe the manual throwaway-venv exercise + the real flake8 check.

verified by execution: code retrieved, value absent from logs; control confirms the old
line carried it.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 20:46:51 -04:00
8 changed files with 159 additions and 37 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# claude # claude
CLAUDE.md .claude/
# python # python
__pycache__/ __pycache__/
+8 -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.4 aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.7
# 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.4 aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.7
``` ```
Direct: Direct:
```bash ```bash
pip install "aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.4" pip install "aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.7"
pip install "aiomail[oauth] @ 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.7"
``` ```
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.4` suffix from the line above to install the latest unpinned. Drop the `@v0.1.7` suffix from the line above to install the latest unpinned.
## Password auth ## Password auth
@@ -66,6 +66,9 @@ await retrieve_otp(client, sender=re.compile(r"no-?reply@.*\.io")) # regex
await retrieve_otp(client, sender=lambda f: f.endswith("@x.com")) # callable await retrieve_otp(client, sender=lambda f: f.endswith("@x.com")) # callable
``` ```
Subject headers are RFC2047-decoded before matching/extraction, so providers
that encode non-ASCII subjects (`=?utf-8?B?...?=`) still match on plain text.
Code extraction is tunable too — `patterns` (regexes, first group wins) and Code extraction is tunable too — `patterns` (regexes, first group wins) and
`lengths` (standalone digit-run fallback): `lengths` (standalone digit-run fallback):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "aiomail" name = "aiomail"
version = "0.1.4" version = "0.1.7"
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 = [
+3 -1
View File
@@ -11,6 +11,7 @@ from .extract import (
DEFAULT_PATTERNS, DEFAULT_PATTERNS,
MatchSpec, MatchSpec,
as_predicate, as_predicate,
decode_header_value,
extract_code, extract_code,
) )
from .retrieve import DEFAULT_FOLDERS, retrieve_otp from .retrieve import DEFAULT_FOLDERS, retrieve_otp
@@ -22,6 +23,7 @@ __all__ = [
"IMAPClient", "IMAPClient",
"extract_code", "extract_code",
"as_predicate", "as_predicate",
"decode_header_value",
"retrieve_otp", "retrieve_otp",
"MatchSpec", "MatchSpec",
"DEFAULT_PATTERNS", "DEFAULT_PATTERNS",
@@ -29,4 +31,4 @@ __all__ = [
"DEFAULT_FOLDERS", "DEFAULT_FOLDERS",
] ]
__version__ = "0.1.4" __version__ = "0.1.7"
+88 -15
View File
@@ -4,6 +4,16 @@ a thin, provider-agnostic client: it owns the connection lifecycle (connect with
retries, reconnect-on-stale, close) and exposes the handful of operations the OTP retries, reconnect-on-stale, close) and exposes the handful of operations the OTP
flow needs (folders, search, fetch, mark-seen). auth is injected, so the same flow needs (folders, search, fetch, mark-seen). auth is injected, so the same
client serves password and OAuth accounts. client serves password and OAuth accounts.
reconnect-on-stale re-selects whatever folder was selected before the drop, so
search/fetch/store keep working against a reconnected session. sequence-number ids
from before a reconnect are not valid afterward (a fresh SELECT can renumber the
mailbox) — pass `use_uid=True` if ids need to survive a reconnect.
concurrency: one `IMAPClient` instance is not safe to drive from multiple
concurrent tasks/coroutines without external serialization; connect/reconnect
internally uses a lock to avoid corrupting `_mail`, but overlapping calls to the
same instance are not the intended usage pattern.
""" """
import asyncio import asyncio
import email import email
@@ -23,16 +33,21 @@ log = logging.getLogger(__name__)
_LIST_RE = re.compile(rb'^\([^)]*\)\s+(?:"[^"]*"|NIL)\s+(.+)$') _LIST_RE = re.compile(rb'^\([^)]*\)\s+(?:"[^"]*"|NIL)\s+(.+)$')
def _folder_name(raw: bytes) -> 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, delimiter-agnostic
parses the real reply form `(flags) "<delim>" <name>` so any server hierarchy parses the real reply form `(flags) "<delim>" <name>` so any server hierarchy
delimiter works (not just "/"); falls back to the last quoted/space token if the delimiter works (not just "/"); returns None if the line doesn't match the
line doesn't match the canonical shape. canonical shape. aioimaplib appends the tagged completion text (e.g. `b"LIST
completed."`) as the final entry of the same response list that carries the
untagged LIST lines — it never matches LIST syntax, so returning None here
(instead of a last-token rsplit fallback) lets the caller drop it instead of
treating it as a phantom folder name.
""" """
match = _LIST_RE.match(raw.strip()) match = _LIST_RE.match(raw.strip())
name = match.group(1).decode() if match else raw.decode().rsplit(" ", 1)[-1] if not match:
return name.strip().strip('"') return None
return match.group(1).decode().strip().strip('"')
class IMAPClient: class IMAPClient:
@@ -62,6 +77,8 @@ class IMAPClient:
self.timeout = timeout self.timeout = timeout
self.max_retries = max_retries self.max_retries = max_retries
self._mail = None self._mail = None
self._selected_folder: Optional[str] = None
self._lock = asyncio.Lock()
async def __aenter__(self) -> "IMAPClient": async def __aenter__(self) -> "IMAPClient":
await self.ensure_connection() await self.ensure_connection()
@@ -71,8 +88,21 @@ 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
await self.close()
serialized by an internal lock: concurrent callers on the same instance queue
up rather than tearing down each other's in-progress handshake. a connection
superseded while a caller waited is logged out, never silently overwritten.
"""
async with self._lock:
return await self._connect_locked()
async def _connect_locked(self) -> bool:
"""connect()'s body; caller must hold self._lock"""
superseded, self._mail = self._mail, None
self._selected_folder = None
if superseded is not None:
await self._discard_mail(superseded)
for attempt in range(self.max_retries): for attempt in range(self.max_retries):
try: try:
if self.use_ssl: if self.use_ssl:
@@ -114,22 +144,60 @@ class IMAPClient:
async def close(self) -> None: async def close(self) -> None:
"""log out and drop the connection, swallowing teardown errors""" """log out and drop the connection, swallowing teardown errors"""
async with self._lock:
await self._close_locked()
async def _close_locked(self) -> None:
"""close()'s body; caller must hold self._lock"""
if self._mail is not None: if self._mail is not None:
mail, self._mail = self._mail, None
try: try:
await self._mail.logout() await mail.logout()
except Exception as exc: except Exception as exc:
log.debug("logout error ignored: %s", exc) log.debug("logout error ignored: %s", exc)
self._mail = None self._selected_folder = None
async def ensure_connection(self) -> bool: async def ensure_connection(self) -> bool:
"""return a live connection, reconnecting if the link is stale""" """return a live, SELECTED-if-applicable connection, reconnecting if the link is stale
if self._mail is None:
return await self.connect() a reconnect only re-authenticates (state AUTH); if a folder was selected before
the drop, it is re-selected here so search/fetch/store keep working afterward.
note: sequence-number ids from before the reconnect are NOT valid against the
new session (a fresh SELECT can renumber/re-EXISTS the mailbox) unless
use_uid=True, in which case UIDs remain stable across the reconnect.
serialized by an internal lock, so concurrent callers on the same instance
never race each other's connect/reconnect; a caller that waits behind another
rechecks liveness first instead of tearing down a connection that just came up.
"""
async with self._lock:
if self._mail is not None:
try: try:
await self._mail.noop() await self._mail.noop()
return True return True
except Exception: except Exception:
return await self.connect() pass
return await self._connect_and_reselect_locked()
async def _connect_and_reselect_locked(self) -> bool:
"""connect() then re-select the previously-selected folder; caller must hold self._lock"""
folder = self._selected_folder
if not await self._connect_locked():
return False
if folder is None:
return True
try:
result, _ = await self._mail.select(f'"{folder}"')
except Exception as exc:
log.debug("re-select %s after reconnect failed: %s", folder, exc)
self._selected_folder = None
return False
if result != "OK":
log.debug("re-select %s after reconnect failed: %s", folder, result)
self._selected_folder = None
return False
self._selected_folder = folder
return True
async def get_folders(self) -> List[str]: async def get_folders(self) -> List[str]:
"""list mailbox folder names""" """list mailbox folder names"""
@@ -143,9 +211,11 @@ class IMAPClient:
folders: List[str] = [] folders: List[str] = []
for folder in folder_list or []: for folder in folder_list or []:
try: try:
folders.append(_folder_name(folder)) name = _folder_name(folder)
except Exception: except Exception:
continue continue
if name is not None:
folders.append(name)
return folders return folders
async def select(self, folder: str) -> bool: async def select(self, folder: str) -> bool:
@@ -154,10 +224,13 @@ class IMAPClient:
return False return False
try: try:
result, _ = await self._mail.select(f'"{folder}"') result, _ = await self._mail.select(f'"{folder}"')
return result == "OK"
except Exception as exc: except Exception as exc:
log.debug("select %s failed: %s", folder, exc) log.debug("select %s failed: %s", folder, exc)
return False return False
if result == "OK":
self._selected_folder = folder
return True
return False
async def search(self, query: str) -> List[int]: async def search(self, query: str) -> List[int]:
"""search the selected folder, returning ids newest-first""" """search the selected folder, returning ids newest-first"""
+23 -3
View File
@@ -8,6 +8,7 @@ filter senders and subjects.
import email.message import email.message
import logging import logging
import re import re
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
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -39,6 +40,23 @@ def _compile(patterns: Sequence[Union[str, Pattern]]) -> list[Pattern]:
return out return out
def decode_header_value(raw: str) -> str:
"""decode an RFC2047 encoded-word header (=?charset?B/Q?...?=) to text
messages are parsed with email.message_from_bytes (compat32 policy), which
leaves encoded-word headers raw; decode before scanning so the subject-first
pass sees real text instead of base64/quoted-printable. falls back to the
raw value if decoding fails.
"""
if not raw:
return raw
try:
return str(make_header(decode_header(raw)))
except (UnicodeDecodeError, LookupError, ValueError) as exc:
log.debug("header decode failed (%s): %s", raw, exc)
return raw
def _decode_part(part: email.message.Message) -> Optional[str]: def _decode_part(part: email.message.Message) -> Optional[str]:
"""decode a single message part to text, tolerating bad charsets""" """decode a single message part to text, tolerating bad charsets"""
payload = part.get_payload(decode=True) payload = part.get_payload(decode=True)
@@ -76,7 +94,7 @@ def _scan(text: str, patterns: list[Pattern], lengths: set[int]) -> Optional[str
return m.group(1) if m.groups() else m.group(0) return m.group(1) if m.groups() else m.group(0)
for token in re.split(r"\s+", text): for token in re.split(r"\s+", text):
digits = "".join(c for c in token if c.isdigit()) digits = "".join(c for c in token if c.isdigit())
if digits and len(digits) in lengths and digits.isdigit(): if digits and len(digits) in lengths:
return digits return digits
return None return None
@@ -97,7 +115,7 @@ def extract_code(
compiled = _compile(patterns) compiled = _compile(patterns)
length_set = set(lengths) length_set = set(lengths)
subject = message.get("Subject", "") or "" subject = decode_header_value(message.get("Subject", "") or "")
hit = _scan(subject, compiled, length_set) hit = _scan(subject, compiled, length_set)
if hit: if hit:
return hit return hit
@@ -121,6 +139,8 @@ def as_predicate(spec: MatchSpec) -> Callable[[Optional[str]], bool]:
if isinstance(spec, re.Pattern): if isinstance(spec, re.Pattern):
return lambda value: bool(spec.search(value or "")) return lambda value: bool(spec.search(value or ""))
if callable(spec): if callable(spec):
return spec # coalesce None like the string/regex branches so the documented Optional[str]
# predicate contract holds even if a caller's callable assumes a real string
return lambda value: bool(spec(value or ""))
needle = str(spec).lower() needle = str(spec).lower()
return lambda value: needle in (value or "").lower() return lambda value: needle in (value or "").lower()
+7 -2
View File
@@ -76,12 +76,17 @@ class _RefreshTokenProvider:
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(endpoint, data=data) as resp: async with session.post(endpoint, data=data) as resp:
if resp.status == 200: if resp.status == 200:
token = (await resp.json()).get("access_token") # content_type=None: some token endpoints return a 200 with
# text/plain or text/javascript; default json() would raise
# ContentTypeError and discard a valid token body
token = (await resp.json(content_type=None)).get("access_token")
if token: if token:
self._failures = 0 self._failures = 0
return token return token
else: else:
body = await resp.text() # log a truncated error body only — a token-endpoint
# response can carry sensitive material; never dump it whole
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:
log.warning("token request to %s failed: %s", endpoint, exc) log.warning("token request to %s failed: %s", endpoint, exc)
+25 -6
View File
@@ -8,11 +8,19 @@ branches inside the function.
import asyncio import asyncio
import logging import logging
import time import time
from datetime import timezone
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from typing import Iterable, List, Optional, Pattern, Sequence, Union from typing import Iterable, List, Optional, Pattern, Sequence, Union
from .client import IMAPClient from .client import IMAPClient
from .extract import DEFAULT_LENGTHS, DEFAULT_PATTERNS, MatchSpec, as_predicate, extract_code from .extract import (
DEFAULT_LENGTHS,
DEFAULT_PATTERNS,
MatchSpec,
as_predicate,
decode_header_value,
extract_code,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -26,12 +34,17 @@ def _server_query(sender: MatchSpec, subject: MatchSpec, match_field: str = "fro
only plain strings translate to server-side filters; regex and callable specs only plain strings translate to server-side filters; regex and callable specs
fall back to ALL and are filtered client-side, so dynamic matching always works fall back to ALL and are filtered client-side, so dynamic matching always works
even when the server cannot express it. `match_field` selects which header the even when the server cannot express it. `match_field` selects which header the
`sender` spec searches: "from" filters by the sender address (default), "to" `sender` spec searches: "from" filters by the sender address (default); "to"
filters by the recipient address (the per-user alias the code was sent to). filters by the recipient address (the per-user alias the code was sent to) OR
the From header, matching the client-side check's 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):
parts.append(f'TO "{sender}"' if match_field == "to" else f'FROM "{sender}"') if match_field == "to":
parts.append(f'OR TO "{sender}" FROM "{sender}"')
else:
parts.append(f'FROM "{sender}"')
if isinstance(subject, str): if isinstance(subject, str):
parts.append(f'SUBJECT "{subject}"') parts.append(f'SUBJECT "{subject}"')
return f"({' '.join(parts)})" if parts else "ALL" return f"({' '.join(parts)})" if parts else "ALL"
@@ -43,7 +56,13 @@ def _age_seconds(message) -> Optional[float]:
if not raw: if not raw:
return None return None
try: try:
return time.time() - parsedate_to_datetime(raw).timestamp() dt = parsedate_to_datetime(raw)
if dt.tzinfo is None:
# parsedate_to_datetime returns a naive datetime for a "-0000" zone
# (RFC 2822: unknown/unspecified offset); treat it as UTC rather
# than letting .timestamp() interpret the wall time as local
dt = dt.replace(tzinfo=timezone.utc)
return time.time() - dt.timestamp()
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
log.debug("date parse failed (%s): %s", raw, exc) log.debug("date parse failed (%s): %s", raw, exc)
return None return None
@@ -97,7 +116,7 @@ async def retrieve_otp(
continue continue
from_hdr = message.get("From", "") from_hdr = message.get("From", "")
subj_hdr = message.get("Subject", "") subj_hdr = decode_header_value(message.get("Subject", ""))
if match_field == "to": if match_field == "to":
to_hdr = message.get("To", "") to_hdr = message.get("To", "")
matched = sender_ok(to_hdr) or sender_ok(from_hdr) matched = sender_ok(to_hdr) or sender_ok(from_hdr)