• v0.26.0 a2b712381f

    v0.26.0
    All checks were successful
    CI / gate (push) Successful in 1m21s
    CI / image (push) Successful in 14s
    Stable

    joeyr released this 2026-08-20 16:43:15 -05:00 | 34 commits to main since this release

    Two gaps in unsubscribe_message, found by reading the code rather than by waiting for
    a message to trip them.
    Both are about the same thing: the tool exists so a POST is not
    made blind, and neither the URI it POSTs to nor the answer it gets back was fully
    accounted for.

    Added

    • A redirect now says where it pointed. redirect_seen reported that the endpoint
      answered 3xx — non-compliant under RFC 8058, which forbids senders from returning one —
      but not the part worth knowing, which is whether the endpoint the sender's DKIM signature
      covered just tried to hand this mailbox to a host it did not. The Location header is
      read off the response already in hand: redirect_host names it, and
      redirect_offsite is true when it differs from the host the POST went to. Both appear
      only on a redirect, so their presence is the signal.

      Still not followed, and that is deliberate. Following would re-POST the subscriber
      token to a host nothing vouched for. This reports a destination; it does not visit one,
      and it costs no second request. Host only, never the Location's query string — the
      same rule that keeps the unsubscribe URI out of logs and responses, and senders routinely
      carry the token through into the redirect.

      On an off-site redirect the response's note says outright that the mismatch is
      something to tell the user rather than act on: the POST has already landed, so there
      is nothing to retry, and a model reading 302 without that sentence will report the
      unsubscribe as having failed.

    Fixed

    • A malformed unsubscribe URI raised instead of refusing. List-Unsubscribe: <https://[oops> passes the scheme check, and urlsplit does not return nothing on an
      authority like that — it raises ValueError("Invalid IPv6 URL"), on the first line of
      post_one_click, past the gate and outside the (MailError, BadMessageId) the tool
      layer catches. A bare https:// with no host got as far as a pointless request. Both are
      now the shape refusal unsubscribe_uri_unusable, decided in decide alongside
      not_https and before anything leaves the machine. Not overridable: there is nowhere to
      send.

      This is the module's own rule applied to a case that escaped it — a refusal is a
      result, not an exception
      , because the caller has to tell the user why nothing happened
      and a traceback is not that sentence.

    Verified

    • The transport-failure path now has tests that exercise the conversion, not just the
      wording it produces. The existing test faked the MailError at the client boundary,
      which proved the envelope and nothing about the except httpx.HTTPError that raises it.
      Four failure modes go through it — DNS, connection refused, connect timeout, read
      timeout — plus assertions that the error text and the log line carry neither the URI nor
      the sender's host, since httpx puts the URL in the message of some transport errors.

      Guarded by a test asserting the fixture actually reaches the POST. A message that quietly
      started failing the gate would leave all four passing while testing nothing.

    • unsubscribe_headers_unsigned was checked and needed no change. It is unconditionally
      blocking: allow_unauthenticated cannot carry it, whether or not a hygiene flag is
      present alongside. Signature coverage is a question about the URI's integrity, not the
      sender's identity, so the authentication override has no business carrying it — already
      the case since 0.24.0, and already pinned by a test.

    Downloads