• v0.29.7 72318a8ef6

    v0.29.7 — refuse an unmatched folder name, and read headers that are malformed
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 16s
    Stable

    joeyr released this 2026-08-22 12:25:27 -05:00 | 12 commits to main since this release

    Two defects found the same way the last three were: a person used the connector and
    asked what an answer meant.
    Checking the Gmail spam folder produced {"messages": [], "count": 0} — read, reasonably, as "the Spam folder is clear". It held 29 messages. Then
    inspecting one of them failed twice with a TypeError about a type nobody had heard of.

    Fixed

    • A folder name that matched nothing was answered with an empty result instead of an
      error.
      folders=["SPAM"] reached IMAP unchanged; SELECT refused it; search_messages
      caught the refusal per folder — correctly, because one bad label must not lose the rest
      of a search — and the caller was handed a clean, successful, empty answer. The real label
      is [Gmail]/Spam.

      The bug is not that the guess failed. It is that failing was indistinguishable from
      succeeding
      , which is this project's recurring defect in its purest form: nothing in the
      response could tell "this folder is empty" from "this folder does not exist", so there
      was no way to be suspicious of it. list_folders was the only route to the exact
      strings, and nothing required calling it first.

      Names are now resolved against the readable labels before any command is sent, in four
      tiers — the exact name, then case-insensitively, then with the [Gmail]/ prefix
      optional, then by RFC 6154 role, so spam, junk, bin and archive land on the right
      label on an account in any language. A name that still matches nothing is refused
      with the closest real label named: "There is no folder named 'Spem'… Did you mean
      '[Gmail]/Spam'?"
      The four tiers are ordered so a user label literally called Spam
      outranks the role folder it shadows.

      Resolution only ever considers labels MAIL_FOLDERS already allows, so it is not a way
      around the allowlist, and the handle path — where a message_id names its own folder —
      is deliberately left matching exactly.

    • A single 8-bit byte in a header took get_message_security out entirely. The error
      was sequence item 0: expected str instance, Header found, and it repeated because
      nothing about it suggested retrying was pointless.

      Message.get does not always return a string. The compat32 policy wraps any header whose
      raw bytes are not ASCII in an email.header.Header, and that is the ordinary shape of
      spam
      — an unencoded em dash in a From or a List-Unsubscribe is a syntax error the
      RFCs forbid and bulk senders emit anyway. So the tool whose entire job is judging
      suspicious mail was lost to the malformation that makes mail suspicious.

      Two more failures sat behind it, neither reported yet and both reachable from the same
      message: decode_header raised LookupError: unknown encoding: unknown-8bit — which is
      the read path, not the security path — and _unsubscribe raised AttributeError on
      Header.strip. Every header now leaves the message through mail/headers.py, which
      flattens Header, str and absent alike, and falls back to UTF-8 for any charset label
      Python cannot look up rather than raising.

      The bytes are decoded, not replaced. str(Header) renders every non-ASCII byte as
      U+FFFD; decode_header hands the original bytes back, and they are nearly always UTF-8,
      so the character the sender wrote is what a reader sees. A header that is both 8-bit
      and RFC 2047 needs two passes to get there — the first decode_header short-circuits
      on a Header and leaves =?utf-8?B?…?= sitting in the output as literal text.

    Notes

    • The Gmail fake refused nothing before this. select_folder accepted any name, so the
      silent-empty defect could not be reproduced against it however many tests were written —
      the same lesson as 0.29.6's crowded label, one file along. It now answers
      NO [NONEXISTENT] Unknown Mailbox the way the server does, and serves whole-message bytes
      so get_message and get_message_security run end to end for the first time.

    • Verified by reverting. Each new test was run against the unpatched code: the tool
      returns {'messages': [], 'count': 0} for ["SPAM"], and the malformed message raises
      TypeError, LookupError and AttributeError in three separate places.

    • The header fix is in mail/, so the iCloud tenant gets it too — it parses the same
      messages with the same functions and had the same crash waiting in it.

    • icloudmail still has the folder defect. Its _search_targets matches names
      literally in exactly the same way. Not fixed here: its folder story is different enough —
      wildcards, no SPECIAL-USE on Drafts — that the resolver is not liftable unchanged, and
      guessing at it without a live pass is how the last one shipped.

    Downloads