@@ 1,11 1,167 @@
---- a/offlineimap/localeval.py 2025-05-04 12:19:37.739860684 +0200
-+++ b/offlineimap/localeval.py 2025-05-04 12:26:07.838113251 +0200
-@@ -26,6 +26,8 @@
- self.namespace = {}
-
- if path is not None:
-+ import importlib.machinery
-+
- # FIXME: limit opening files owned by current user with rights set
- # to fixed mode 644.
- foo = open(path, 'r')
+From 1c4f9b4f05de575cd99ac5bad68c6e0bbad25374 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Rodolfo=20Garc=C3=ADa=20Pe=C3=B1as=20=28kix=29?=
+ <kix@kix.es>
+Date: Wed, 13 May 2026 18:35:54 +0200
+Subject: [PATCH] Enhance STARTTLS handling with fallback for non-standard IMAP
+ servers
+
+- Implement tolerance for non-standard IMAP servers that do not provide reliable CAPABILITY after STARTTLS.
+- Introduce configuration option 'allow_nonstandard_capabilities' to enable fallback using pre-TLS capabilities.
+---
+ offlineimap/imapserver.py | 110 +++++++++++++++++++++++++++------
+ offlineimap/repository/IMAP.py | 7 +++
+ 2 files changed, 98 insertions(+), 19 deletions(-)
+
+diff --git a/offlineimap/imapserver.py b/offlineimap/imapserver.py
+index fd866867..b29b5d6c 100644
+--- a/offlineimap/imapserver.py
++++ b/offlineimap/imapserver.py
+@@ -363,32 +363,95 @@ def __gsshandler(self, token):
+ return None
+
+ def __start_tls(self, imapobj):
+- if 'STARTTLS' in imapobj.capabilities and not self.usessl:
+- self.ui.debug('imap', 'Using STARTTLS connection')
+- # imaplib2.starttls() unconditionally calls _get_capabilities() right
+- # after the TLS handshake (before setting _tls_established=True).
+- # Servers like Protonmail Bridge do not respond to CAPABILITY at
+- # that stage, causing a 60-second hang. We bypass it by temporarily
+- # substituting a no-op; imaplib2 still sets _tls_established=True
+- # normally, and acquireconnection() already guards its own
+- # post-login CAPABILITY refresh against that flag (see below).
+- _orig_get_cap = imapobj._get_capabilities
+- imapobj._get_capabilities = lambda: None
++ """Upgrade connection to TLS if STARTTLS is configured.
++
++ - Uses pre-TLS capabilities only to decide whether to perform STARTTLS.
++ - After STARTTLS, forces a CAPABILITY command and replaces internal
++ capabilities with the post-TLS response.
++ - If no reliable post-TLS CAPABILITY is available:
++ - In strict mode (allow_nonstandard_capabilities = no): abort.
++ - In tolerant mode (allow_nonstandard_capabilities = yes):
++ reuse pre-TLS capabilities, removing LOGINDISABLED.
++ """
++
++ # If the repository does not want STARTTLS, do nothing.
++ if not self.starttls or self.usessl:
++ return
++
++ # Pre-TLS capabilities (from initial banner / CAPABILITY).
++ caps_pre = set(getattr(imapobj, '_offlineimap_capabilities_pre_tls',
++ getattr(imapobj, 'capabilities', [])))
++
++ # If the server does not advertise STARTTLS, respect that.
++ if 'STARTTLS' not in caps_pre:
++ return
++
++ # Execute STARTTLS.
++ self.ui.debug('imap', 'Using STARTTLS connection')
++
++ # Disable _get_capabilities() during starttls() to prevent imaplib2
++ # from issuing CAPABILITY before we can do it ourselves.
++ _orig_get_cap = imapobj._get_capabilities
++ imapobj._get_capabilities = lambda: None
++ try:
+ try:
+ imapobj.starttls()
+ except imapobj.error as e:
+ err = "Failed to start TLS connection: %s" % str(e)
+ raise OfflineImapError(err, OfflineImapError.ERROR.REPO,
+ exc_info()[2])
+- finally:
+- # Always restore the original method, even on success.
+- imapobj._get_capabilities = _orig_get_cap
+- elif self.starttls and not self.usessl:
++ finally:
++ # Always restore the original method, even on error.
++ imapobj._get_capabilities = _orig_get_cap
++
++ # At this point, the socket is encrypted. Now we must refresh CAPABILITY.
++ caps_post = None
++ try:
++ typ, data = imapobj.capability()
++ if typ == 'OK' and data:
++ # data is a list of bytes, e.g. [b'IMAP4rev1 IDLE AUTH=PLAIN']
++ line = data[0]
++ if isinstance(line, bytes):
++ line = line.decode('ascii', 'ignore')
++ caps_post = set(line.upper().split())
++ except Exception:
++ caps_post = None
++
++ if caps_post is not None:
++ # Standard path: completely replace capabilities
++ imapobj.capabilities = caps_post
++ imapobj._offlineimap_capabilities_post_tls = caps_post
++ return
++
++ # If we reach here, we do not have reliable post-TLS capabilities.
++ # Decide based on repository configuration.
++ allow_nonstandard = getattr(self.repos,
++ 'allow_nonstandard_capabilities', False)
++
++ if not allow_nonstandard:
++ # Strict mode: abort with clear error.
+ raise OfflineImapError(
+- "STARTTLS requested but server does not advertise STARTTLS capability. "
+- "Aborting to prevent sending password in plaintext (possible MITM attack). "
+- "If you REALLY want an insecure connection, set 'starttls = no'.",
+- OfflineImapError.ERROR.REPO)
++ "Server did not provide valid CAPABILITY after STARTTLS; "
++ "set 'allow_nonstandard_capabilities = yes' in repository "
++ "configuration to enable a non-standard fallback.",
++ OfflineImapError.ERROR.REPO
++ )
++
++ # Tolerant mode: best-effort using caps_pre.
++ caps_fallback = set(caps_pre)
++ if 'LOGINDISABLED' in caps_fallback:
++ # We assume that after STARTTLS, LOGINDISABLED no longer applies,
++ # so we remove it to allow LOGIN/AUTH configured by the user.
++ caps_fallback.remove('LOGINDISABLED')
++
++ imapobj.capabilities = caps_fallback
++ imapobj._offlineimap_capabilities_post_tls = caps_fallback
++
++ self.ui.warn(
++ "Server did not provide CAPABILITY after STARTTLS; "
++ "falling back to pre-TLS capabilities without LOGINDISABLED "
++ "due to allow_nonstandard_capabilities = yes."
++ )
+
+ # All __authn_* procedures are helpers that do authentication.
+ # They are class methods that take one parameter, IMAP object.
+@@ -605,6 +668,15 @@ def acquireconnection(self):
+ self.lastowner[imapobj] = curThread.ident
+ self.connectionlock.release()
+
++ # Store the pre-TLS capabilities (from banner and initial CAPABILITY).
++ # This will be used only to decide STARTTLS and, in case of non-standard
++ # fallback, to reconstruct an approximate post-TLS capability list.
++ try:
++ caps_pre = set(getattr(imapobj, 'capabilities', []))
++ except Exception:
++ caps_pre = set()
++ imapobj._offlineimap_capabilities_pre_tls = caps_pre
++
+ # Verify that the connection is still alive before returning it
+ # to the caller. If not, clean up and recursively call
+ # acquireconnection() to get a new one.
+diff --git a/offlineimap/repository/IMAP.py b/offlineimap/repository/IMAP.py
+index b1afe861..db64dae1 100644
+--- a/offlineimap/repository/IMAP.py
++++ b/offlineimap/repository/IMAP.py
+@@ -38,6 +38,13 @@ def __init__(self, reposname, account):
+ self.idlefolders = None
+ BaseRepository.__init__(self, reposname, account)
+ # self.ui is being set by the BaseRepository
++
++ # Allow tolerance for non-standard IMAP servers that do not provide
++ # reliable CAPABILITY after STARTTLS. Default is strict (False).
++ # If set to True, heuristics using pre-TLS capabilities are applied
++ # as a fallback.
++ self.allow_nonstandard_capabilities = self.getconfboolean(
++ 'allow_nonstandard_capabilities', False)
+ self._host = None
+ # Must be set before calling imapserver.IMAPServer(self)
+ self.oauth2_request_url = None