Skip to content

Fix starttls() compatibility with Python 3.14 - #663

Merged
mjs merged 1 commit into
mjs:masterfrom
LarsArtmann:fix-starttls-py314
Sep 18, 2026
Merged

mjs merged 1 commit into
mjs:masterfrom
LarsArtmann:fix-starttls-py314

Conversation

@LarsArtmann

Copy link
Copy Markdown
Contributor

Why

Python 3.14 turned imaplib.IMAP4.file into a read-only property, so every STARTTLS connection now dies:

AttributeError: property 'file' of 'IMAP4WithTimeout' object has no setter

starttls() still does the manual socket wrap + self._imap.file = ... reassignment (imapclient/imapclient.py:387 on master). #641 removed the same assignment from IMAP4WithTimeout.open(); this is the remaining site (#662).

What changed

  • starttls() delegates to imaplib.IMAP4.starttls(ssl_context), which manages the wrap and its own read buffer (self._file on 3.14+) the way the running Python expects. Same direction as Fix IMAP4WithTimeout compatibility with Python 3.14 #641: leave buffering to imaplib instead of duplicating it. (The 2017 objections in Use imaplib's starttls if available #240/Use imaplib's starttls if available #314 were about swapping a then-working manual path for imaplib's; the manual path is the thing that is broken now.)
  • IMAPClient's documented default is preserved: with no context, tls.create_default_context() (hostname checking + CERT_REQUIRED) is passed explicitly, because imaplib's own None-fallback is ssl._create_stdlib_context() - unverified. The helper is now shared with tls.wrap_socket().
  • _checkok stays as a belt for non-raising stdlib paths.

Two observable deltas: the return value is now None (was the tagged OK text - undocumented, unused in-repo), and a refusal surfaces as imaplib's generic Couldn't establish TLS session instead of the server's own text (same exception class - IMAPClientError is an alias of imaplib.IMAP4.error).

While testing I noticed master fails on Python 3.8 independently of this change: IMAP4WithTimeout.__init__ passes timeout= to imaplib.IMAP4, which only accepts it since 3.9 (7 suite errors on pristine master). Left alone here; the new integration tests skip themselves below 3.9 for that reason.

Test plan

  • tests/test_starttls.py updated to the delegation seam, incl. asserting the default-context passthrough
  • New tests/test_starttls_integration.py: real-socket STARTTLS round trip against an in-process fake IMAP server (throwaway CA in tests/certs/, re-included in .gitignore for global *.pem/*.key ignorers). Asserts the verifying default (CERT_REQUIRED + check_hostname), custom-context identity, post-TLS capability refresh, refusal error mapping. Fails on unfixed master at 3.14, passes with the fix.
  • python -m unittest green on 3.14.7, 3.13, pypy3.9; 3.8 green with the integration class skipped (pre-existing timeout= issue above)
  • black --check ., isort --check ., flake8, pylint imapclient/ (10.00/10), mypy - all clean

Fixes: #662
Downstream: NixOS/nixpkgs#563652 (nixpkgs has defaulted to Python 3.14; parsedmarc STARTTLS is broken there)

💘 Generated with Crush

Python 3.14 turned imaplib.IMAP4.file into a read-only property, so the
manual socket wrap + file reassignment in starttls() crashed on every
STARTTLS connection:

    AttributeError: property 'file' of 'IMAP4WithTimeout' object has no setter

Delegate to imaplib.IMAP4.starttls(), which manages the wrap and its own
read buffer (self._file on 3.14+) in the way the running Python expects -
the same direction as mjs#641. IMAPClient's documented default of a verifying
context is preserved by passing tls.create_default_context() explicitly
(imaplib's own None-fallback, ssl._create_stdlib_context(), does not
verify certificates); the helper is now shared with tls.wrap_socket().

Fixes mjs#662
@mjs

mjs commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Thanks for the catch and the comprehensive solution. Having the integration test is excellent.

@mjs
mjs merged commit 2bd3861 into mjs:master Sep 18, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

starttls() still assigns read-only IMAP4.file on Python 3.14 (4.0.1 + master)

2 participants