Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@ six-*

# By explicity not ignoring these, ripgrep (rg) will search them.
!.github/

# TLS fixtures for tests/test_starttls_integration.py. Re-included because
# common global gitignore setups exclude *.pem and *.key.
!tests/certs/ca.pem
!tests/certs/server.pem
!tests/certs/server.key
12 changes: 8 additions & 4 deletions imapclient/imapclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,13 +378,17 @@ def starttls(self, ssl_context=None):
if self.ssl or self._starttls_done:
raise exceptions.IMAPClientAbortError("TLS session already established")

typ, data = self._imap._simple_command("STARTTLS")
# Delegate the socket wrap to imaplib: it manages its own read buffer
# since Python 3.14 (where IMAP4.file became a read-only property).
# imaplib's own None-fallback context does not verify certificates, so
# pass IMAPClient's verifying default instead.
if ssl_context is None:
ssl_context = tls.create_default_context()

typ, data = self._imap.starttls(ssl_context=ssl_context)
self._checkok("starttls", typ, data)

self._starttls_done = True

self._imap.sock = tls.wrap_socket(self._imap.sock, ssl_context, self.host)
self._imap.file = self._imap.sock.makefile("rb")
return data[0]

def login(self, username: str, password: str):
Expand Down
6 changes: 5 additions & 1 deletion imapclient/tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@
from typing import Optional


def create_default_context() -> ssl.SSLContext:
return ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)


def wrap_socket(
sock: socket.socket, ssl_context: Optional[ssl.SSLContext], host: str
) -> socket.socket:
if ssl_context is None:
ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
ssl_context = create_default_context()

return ssl_context.wrap_socket(sock, server_hostname=host)

Expand Down
20 changes: 20 additions & 0 deletions tests/certs/ca.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDLTCCAhWgAwIBAgIUD5Mcp+UoT5Lvbfxg9bbB2XdTaqgwDQYJKoZIhvcNAQEL
BQAwHTEbMBkGA1UEAwwSaW1hcGNsaWVudC10ZXN0LWNhMCAXDTI2MDkxNjA3MzAy
N1oYDzIxMjYwODIzMDczMDI3WjAdMRswGQYDVQQDDBJpbWFwY2xpZW50LXRlc3Qt
Y2EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4AzJ8Egl43Y8qrsEo
xvozZxSS5kbdtw7fNJqF8eIgjEUNTkmU8SW1zYSlH9Grx3GKmq2En3yTa3n1Zv/f
2ShuGf3coTah+W7jV4leYaYqZfnOMBHi3FdGzqswOvtUPFVodfUe0/lnGaa1xDDL
sL2/EemuDXAyq7u8dvvH+Hx10/hg6DI40+Spy3P/8AjVSbuqLaqcDE3UBRamIUAJ
9pVUwOgQToSpbwFCaAxXLEGExVvsai2yl0/iIYXJRKMWrkJJC2ZPS97VEsOkkdZ8
lzpBRHuLKXueiK7hmE1ZC2H6wcU0FdNGQOv/jId4tUYUpBN8whCQXfRO3FTOZmWS
g885AgMBAAGjYzBhMB0GA1UdDgQWBBSVgPEGykrlWHTLZGp3/KzTUQdXrjAfBgNV
HSMEGDAWgBSVgPEGykrlWHTLZGp3/KzTUQdXrjAPBgNVHRMBAf8EBTADAQH/MA4G
A1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEApVCDIBAleI4TRr7xNBun
ulivr2RpLrBAYBaOfHH99r2hq13bT3nU8gM1h0hdqWIEOSfxmqs9Poy0GF0Jw3uW
lsMUrWHlnAz5SLcRhp4C03VRxQ2p8EsrErboljfzZswbEzPY6KlwCRKl+9wL42dA
xyARhizYn935Hm3pfZhEcZ04UunFf+0QmnTO37XeBWzeSVVS78i1l3UVnZ6gaLwI
uR2F972vZezjiPoq71sVeei/24RMSG+ayGBX6CN7bQ3Sdm0hNp+HTx9GHVoilZ6+
cMa3qE82B3uwchjmbODfKuzhWJDU2zdn/xCf1SIk3U4KxF5nnaFecsWgQ2SXFsUN
Rg==
-----END CERTIFICATE-----
28 changes: 28 additions & 0 deletions tests/certs/server.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMxbwcLE650p6R
Q0pl0WsyDWm2dkWS8OGkcFZbzoCBg0mjBQmJZmc7Qr3/m+/0KZ+Zpw1tfVHNPQB1
sFjevpRbYYMhtwFp0id4TmoqCcjAzvluNntB9oFZMtckL+oGnZA2Yebdl6x3AOZc
Uf5lE6pCwFf0z0MHoqQ4+f/4rwb8cCVM5Gc4TLmUzPXyopfH6HzIZYvCH9+eAP+r
/Z6tHMXxNX5s3X1/2BhV6L/xZxS8fTjvzEZe486EvnR3vrBg8LU2h6em1xL41FJy
ye7Wl/V+nGAEIzOfasFrn9c5Uof2Qr+1oyo+PxKmBeKujFPxvj/YWBzFR27MtUTV
Kkw2jPTHAgMBAAECggEAHfnqUNjUSNPs04iyvkH7ERoxOf00BSU5xZfjryUv3wW9
mgg1k7AdIj83JNE1Phx5GTCrwLX3liBnzf1b8nZFWIqR/DWtQXT6wzFTeEZIuvTx
mFvlt6Ei4VBrJIiBF/5+gH+HdjPrEtwGXPMXLGvFtmeFg2Md8GMI6wWp85kyYBy1
ZUkMGRoIS+RuJWxrDAH8ExNuy5eOLibV9Qd517Mx4GSdU6bWeNJUZce96oYI0wRX
XiPCJTnnyaXGx6FYpjvLktsFMJeTCh0/jnwVwVUWIuXuaLS14ZgtuVNsIKtdTk42
LDpxbCvx8VP1fIdx6tI6UydvOPe5aWb1MIrt7kzoiQKBgQD4SV2vYMvAEasX+ZdW
e9pgoo7lVmnKo4mkKlyCtJ1S5XbeRYwd8zFiMA0DYHjWBxKIskTZkW0fgzbYxWu3
2oBOZUK4blAbOp2fcDDwijmQS47fJP+aiisr1CQ7dgi6AUG3mFe3GrpUV4izb3lD
zAa0BQSTlOzYxXbE0V96U1F6fwKBgQDTIkxz5FgZCEK6J1ocZ31bZCmqtjP9uhB/
UvcwsL+gWSMFokbjPRgjdszJ1ZExlVM21qMnvIXBUYxdQksQn3lyuSq2Qt+cvwdb
jVztGAqj3wcwXgxqlA18Y4VoN1dA+j4TcFl0aXysRRuaNXBh2bn9Ag037QudNRKW
4yQ2GfcRuQKBgQDC7e7Eqy0sPXLjXsyHVLOtnNKtkXt7YYvFvJBm+eBuXcNGXLnW
JcHGwczlss8L0f+mHLfbTMasr/rWk6Cvdre3VN7eJ5Vy20E3FQEHSK5K8FdaMBUR
QpsETQipOlE9n5D10IBFPJG/d/3WSpWdOciley8NoyWURuupOGxKgLF+hwKBgATA
ed4F3qG5mN7dsd5/ycd7yqBH94WXYXWsJVoYMEfomawvyc6lelyZ+OP2BY2LIdNT
I1rqmEwQjMswicX3AOG/HyH5AVViy0SZOuUvLFYh1+STyU3AxUjuaHs++5y/xRPk
7G59TUwHRibDWyMoplH2cNu4XxGLtz5kIFWDYhcpAoGBALeoIbG5MnU67w4Z5PlN
8Nvg2Z59764zentOnDdyDNNq4ocglZGerrKIenyTQ5hcBAxeDOGVnsIPSZWaQRyp
2ePAyMmckQByJQlX3KZLQUUTcHHxohWLRowO5FfJJ5nLWOE4tcB2+B2NUvSfZ+Ax
gi0Jt2Sxc3Lfxs/S8VQynSK+
-----END PRIVATE KEY-----
20 changes: 20 additions & 0 deletions tests/certs/server.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDNDCCAhygAwIBAgIUE4e/PDZJBjDTSF6yhUrM4qLOQz4wDQYJKoZIhvcNAQEL
BQAwHTEbMBkGA1UEAwwSaW1hcGNsaWVudC10ZXN0LWNhMCAXDTI2MDkxNjA3MzAy
N1oYDzIxMjYwODIzMDczMDI3WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0G
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDMxbwcLE650p6RQ0pl0WsyDWm2dkWS
8OGkcFZbzoCBg0mjBQmJZmc7Qr3/m+/0KZ+Zpw1tfVHNPQB1sFjevpRbYYMhtwFp
0id4TmoqCcjAzvluNntB9oFZMtckL+oGnZA2Yebdl6x3AOZcUf5lE6pCwFf0z0MH
oqQ4+f/4rwb8cCVM5Gc4TLmUzPXyopfH6HzIZYvCH9+eAP+r/Z6tHMXxNX5s3X1/
2BhV6L/xZxS8fTjvzEZe486EvnR3vrBg8LU2h6em1xL41FJyye7Wl/V+nGAEIzOf
asFrn9c5Uof2Qr+1oyo+PxKmBeKujFPxvj/YWBzFR27MtUTVKkw2jPTHAgMBAAGj
czBxMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATATBgNVHSUEDDAKBggrBgEF
BQcDATAdBgNVHQ4EFgQURGBCabNOuiMbjOMX3ToFlz604EUwHwYDVR0jBBgwFoAU
lYDxBspK5Vh0y2Rqd/ys01EHV64wDQYJKoZIhvcNAQELBQADggEBAI7fV80sIa7K
2VubGYe+MlSfF4R8SYAJBDep9GJjvknrl5/92QclSHgBMkK3A6roKySLLtBhc71O
ODLVhLydFIUWdZjJ8DHEE3x4ySljytlanifxwzGbqXepXplBL8yIlF4CBWfZv3BX
BvwMWIsoTXWVC4zM+MVfiEMd8dgrc9jYMkXRIu8MqWqMEOZCKTXXOoHZnY6q4U3O
gCqiwl6e56aaVm6ChXl8GyVkLq9QH0hE61Enuj0hq4V1N2N07Dqa1JHZ+KHFCebn
+7H9cUVvbXhn1bXuIFYRfVVW5KmCQkSazbq8pIWYdul9e2+BTShfcCyiXOmSpIzL
Sh75FKlil8E=
-----END CERTIFICATE-----
33 changes: 15 additions & 18 deletions tests/test_starttls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Released subject to the New BSD License
# Please see http://en.wikipedia.org/wiki/BSD_licenses

from unittest.mock import Mock, patch, sentinel
from unittest.mock import patch, sentinel

from imapclient.exceptions import IMAPClientError
from imapclient.imapclient import IMAPClient
Expand All @@ -18,34 +18,31 @@ def setUp(self):
self.tls = patcher.start()
self.addCleanup(patcher.stop)

self.client._imap.sock = sentinel.old_sock

self.new_sock = Mock()
self.new_sock.makefile.return_value = sentinel.file
self.tls.wrap_socket.return_value = self.new_sock

self.client.host = sentinel.host
self.client.ssl = False
self.client._starttls_done = False
self.client._imap._simple_command.return_value = "OK", [
b"start TLS negotiation"
]
self.client._imap.starttls.return_value = "OK", [b"start TLS negotiation"]
self.client._cached_capabilities = [b"STARTTLS"]

def test_works(self):
resp = self.client.starttls(sentinel.ssl_context)

self.tls.wrap_socket.assert_called_once_with(
sentinel.old_sock,
sentinel.ssl_context,
sentinel.host,
self.client._imap.starttls.assert_called_once_with(
ssl_context=sentinel.ssl_context
)
self.tls.create_default_context.assert_not_called()
self.assertTrue(self.client._starttls_done)
self.assertEqual(resp, b"start TLS negotiation")

def test_default_context_is_used(self):
resp = self.client.starttls()

self.client._imap.starttls.assert_called_once_with(
ssl_context=self.tls.create_default_context.return_value
)
self.new_sock.makefile.assert_called_once_with("rb")
self.assertEqual(self.client._imap.file, sentinel.file)
self.assertEqual(resp, b"start TLS negotiation")

def test_command_fails(self):
self.client._imap._simple_command.return_value = "NO", [b"sorry"]
self.client._imap.starttls.return_value = "NO", [b"sorry"]

with self.assertRaises(IMAPClientError) as raised:
self.client.starttls(sentinel.ssl_context)
Expand Down
162 changes: 162 additions & 0 deletions tests/test_starttls_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Copyright (c) 2025, Menno Smits
# Released subject to the New BSD License
# Please see http://en.wikipedia.org/wiki/BSD_licenses

"""Integration tests for starttls() over a real socket pair.

These exercise the actual imaplib starttls code path (socket wrap plus
read-buffer refresh), which unit tests with mocked IMAP4 objects cannot
cover. This is the path that broke on Python 3.14, where IMAP4.file
became a read-only property.
"""

import os
import socket
import ssl
import sys
import threading
import unittest
from pathlib import Path

from imapclient.exceptions import IMAPClientError
from imapclient.imapclient import IMAPClient

CERT_DIR = Path(__file__).parent / "certs"
CA_FILE = CERT_DIR / "ca.pem"
SERVER_CERT = CERT_DIR / "server.pem"
SERVER_KEY = CERT_DIR / "server.key"

PRE_TLS_CAPS = b"IMAP4rev1 STARTTLS AUTH=PLAIN"
POST_TLS_CAPS = b"IMAP4rev1 AUTH=PLAIN"


class FakeSTARTTLSServer:
"""Minimal IMAP4 server that upgrades to TLS when told STARTTLS.

Serves one connection per instance. Post-TLS CAPABILITY responses omit
STARTTLS, mirroring what real servers advertise once the connection is
secured.
"""

def __init__(self):
self.refuse_tls = False
self.error = None
self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self.ctx.load_cert_chain(str(SERVER_CERT), str(SERVER_KEY))
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(1)
self.port = self.sock.getsockname()[1]
self.thread = threading.Thread(target=self._serve, daemon=True)
self.thread.start()

def _reply_caps(self, conn, tag, caps):
conn.sendall(b"* CAPABILITY " + caps + b"\r\n")
conn.sendall(tag + b" OK CAPABILITY completed\r\n")

def _serve(self):
try:
conn, _ = self.sock.accept()
conn.sendall(
b"* OK [CAPABILITY " + PRE_TLS_CAPS + b"] fake server ready\r\n"
)
f = conn.makefile("rb")
while True:
line = f.readline()
if not line:
break
parts = line.split(None, 1)
tag = parts[0]
cmd = parts[1].strip().upper() if len(parts) > 1 else b""
if cmd.startswith(b"CAPABILITY"):
self._reply_caps(conn, tag, PRE_TLS_CAPS)
elif cmd.startswith(b"STARTTLS"):
if self.refuse_tls:
conn.sendall(tag + b" NO TLS refused\r\n")
continue
conn.sendall(tag + b" OK Begin TLS now\r\n")
self._serve_tls(self.ctx.wrap_socket(conn, server_side=True))
return
elif cmd.startswith(b"LOGOUT"):
conn.sendall(b"* BYE fake server signing off\r\n")
conn.sendall(tag + b" OK LOGOUT completed\r\n")
conn.close()
return
else:
conn.sendall(tag + b" OK done\r\n")
except Exception as e:
self.error = e

def _serve_tls(self, conn):
f = conn.makefile("rb")
while True:
line = f.readline()
if not line:
break
parts = line.split(None, 1)
tag = parts[0]
cmd = parts[1].strip().upper() if len(parts) > 1 else b""
if cmd.startswith(b"CAPABILITY"):
self._reply_caps(conn, tag, POST_TLS_CAPS)
elif cmd.startswith(b"LOGOUT"):
conn.sendall(b"* BYE fake server signing off\r\n")
conn.sendall(tag + b" OK LOGOUT completed\r\n")
conn.close()
return
else:
conn.sendall(tag + b" OK done\r\n")


@unittest.skipIf(
sys.version_info < (3, 9),
"imaplib.IMAP4 gained the timeout parameter in Python 3.9",
)
class StarttlsSocketTests(unittest.TestCase):
def setUp(self):
self.server = FakeSTARTTLSServer()
self._saved_cert_file = os.environ.pop("SSL_CERT_FILE", None)
os.environ["SSL_CERT_FILE"] = str(CA_FILE)

def tearDown(self):
if self._saved_cert_file is not None:
os.environ["SSL_CERT_FILE"] = self._saved_cert_file
self.server.sock.close()
self.assertIsNone(self.server.error)

def _client(self):
return IMAPClient("localhost", self.server.port, ssl=False)

def test_starttls_with_default_context(self):
client = self._client()

client.starttls()

sock = client._imap.sock
self.assertIsInstance(sock, ssl.SSLSocket)
# IMAPClient's documented default: hostname checking and certificate
# verification ON. imaplib's own None-fallback is unverified.
self.assertTrue(sock.context.check_hostname)
self.assertEqual(sock.context.verify_mode, ssl.CERT_REQUIRED)

self.assertNotIn(b"STARTTLS", client.capabilities())
client.login("user", "pass")
client.logout()

def test_starttls_with_custom_context(self):
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE

client = self._client()
client.starttls(context)

self.assertIs(client._imap.sock.context, context)
client.login("user", "pass")
client.logout()

def test_starttls_refused_raises_imapclient_error(self):
self.server.refuse_tls = True
client = self._client()

with self.assertRaises(IMAPClientError):
client.starttls()
Loading