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>
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>
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>
_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>
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>
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>
aiomail-7: log a truncated body when a 200 token response has no access_token
instead of silently discarding it. aiomail-8: connect() no longer sleeps after
the last failed retry attempt. aiomail-9: _discard_mail now distinguishes its
own task.cancel() from an external cancellation via asyncio.shield, so
connect() no longer resists cancellation. aiomail-10: the digit-run fallback
now matches contiguous \d+ runs instead of mashing digits across punctuation
within a whitespace token (e.g. a date). aiomail-11: README's dynamic-matching
example uses email.utils.parseaddr since a real From header is not a bare
address. Also compresses essay docstrings to one-line-plus-nuance with zero
behavior change (re-verified against the same negative controls).
Signed-off-by: disqualifier <dev@disqualifier.me>
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>
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>
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>
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>
- 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 <dev@disqualifier.me>
select the literal payload by isinstance bytearray instead of len>20. aioimaplib
stores the message body as the only bytearray in the response; every other line
(including the '<id> FETCH (...' header) is plain bytes. the length heuristic
matched the header line first for any 2+ digit message id or BODY[]/UID fetch,
returning a blank Message and silently breaking OTP retrieval on real mailboxes.
Signed-off-by: disqualifier <dev@disqualifier.me>
the clean lib matched senders by From only; the original imap_tool.py matched primarily by TO (the per-user alias the code was sent to) with a HEADER FROM forwarded fallback. added match_field="from"|"to" to retrieve_otp: "from" (default) is byte-identical to current behavior, "to" searches TO primary and accepts a forwarded From match, restoring the alias flow. server query + client-side predicate both honor it. bump to v0.1.2.
Signed-off-by: disqualifier <dev@disqualifier.me>
aioimaplib's mail.xoauth2(user, token) builds the SASL string by f-string interpolating the token, so a bytes token injects the b'...' repr into auth=Bearer and breaks every XOAUTH2 login. dropped the .encode() (token is already str via _resolve_token/_as_str). corrected the inline comment and the CLAUDE.md note that both wrongly claimed bytes was required — that false note propagated the bug through prior review passes.
Signed-off-by: disqualifier <dev@disqualifier.me>
on a failed connect attempt the IMAP4 object was dropped without logout(), leaking the socket aioimaplib held; now it is logged out (best-effort) before nulling. also moved the asyncio import in oauth.py from inside the retry loop to module top.
Signed-off-by: disqualifier <dev@disqualifier.me>
- a token provider (or static token) returning bytes crashed token.encode() in the
XOAUTH2 path. coerce to str at the source (_resolve_token via _as_str) so both
the .encode() and SASL-fallback entrypoints get a str.
- client.search() did int(x) on every SEARCH token unguarded; a malformed/non-
numeric token aborted the whole search. skip non-numeric tokens (log at debug)
instead of crashing.
verified by execution: bytes static + async-provider tokens authenticate without
crashing (both xoauth2 and SASL-fallback paths); guarded search skips garbage.
Signed-off-by: disqualifier <dev@disqualifier.me>