From 19cbc357c7cafaa1baed2636bb4c75e345dd605d Mon Sep 17 00:00:00 2001 From: Ivo Date: Wed, 9 Sep 2026 14:46:53 +0000 Subject: [PATCH] imaplibutil: report a useful error when the IMAP server is unreachable rfc6555 raises errno-only socket errors without any message text when no address can be connected (e.g. connection refused or timed out), because its _is_acceptable_errno() builds socket.error() without arguments. open_socket() propagated those as-is, so a failed connection surfaced as an empty error with no explanation. Debian bug #982829 reports the same user-facing failure class: an unreachable IMAP server yields a coding error instead of a message describing the problem. Rebuild errno-only errors with os.strerror() text before re-raising. This makes imapserver.py's existing ECONNREFUSED handler match again (its check is e.args[0] == errno.ECONNREFUSED, which an args-less error never satisfied) and gives every other failure path a readable message. Add unit tests covering errno-only refused/timeout errors and the pass-through of already-messageful errors. Signed-off-by: Ivo --- offlineimap/imaplibutil.py | 16 +++++-- test/tests/test_00_imaplibutil.py | 70 +++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 test/tests/test_00_imaplibutil.py diff --git a/offlineimap/imaplibutil.py b/offlineimap/imaplibutil.py index d777c1a..a713709 100644 --- a/offlineimap/imaplibutil.py +++ b/offlineimap/imaplibutil.py @@ -80,10 +80,18 @@ class UsefulIMAPMixIn: """open_socket() Open socket choosing first address family available.""" if self.af == socket.AF_UNSPEC: - # happy-eyeballs! - return rfc6555.create_connection((self.host, self.port)) - else: - return self._open_socket_for_af(self.af) + try: + # happy-eyeballs! + return rfc6555.create_connection((self.host, self.port)) + except socket.error as e: + if not e.args and e.errno: + # rfc6555 may raise an errno-only error with no message + # text (e.g. when the IMAP server is unreachable), which + # users would see as an empty failure. Rebuild the error + # with the errno description so the reason is reported. + raise socket.error(e.errno, os.strerror(e.errno)) from e + raise + return self._open_socket_for_af(self.af) def _open_socket_for_af(self, af): for res in socket.getaddrinfo(self.host, self.port, af, socket.SOCK_STREAM): diff --git a/test/tests/test_00_imaplibutil.py b/test/tests/test_00_imaplibutil.py new file mode 100644 index 0000000..b699cea --- /dev/null +++ b/test/tests/test_00_imaplibutil.py @@ -0,0 +1,70 @@ +# Copyright (C) 2012- Sebastian Spaeth & contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +"""Unit tests for offlineimap.imaplibutil, no network required.""" + +import errno +import socket +import unittest +from unittest import mock + +from offlineimap import imaplibutil + + +class TestOpenSocket(unittest.TestCase): + """open_socket() must report connect failures with a useful message. + + rfc6555 can raise a socket.error that carries only an errno and no + message text when the IMAP server is unreachable. UsefulIMAPMixIn + must turn that into an error users can actually read. + """ + + def _make_mixin(self): + mixin = object.__new__(imaplibutil.UsefulIMAPMixIn) + mixin.host = '127.0.0.1' + mixin.port = 1 + mixin.af = socket.AF_UNSPEC + return mixin + + def _patch_create_connection(self, err): + return mock.patch.object( + imaplibutil.rfc6555, 'create_connection', side_effect=err) + + def test_connect_refused_reports_errno_text(self): + err = socket.error() + err.errno = errno.ECONNREFUSED + with self._patch_create_connection(err): + with self.assertRaises(socket.error) as cm: + self._make_mixin().open_socket() + self.assertIn('Connection refused', str(cm.exception)) + + def test_connect_timeout_reports_errno_text(self): + err = socket.error() + err.errno = errno.ETIMEDOUT + with self._patch_create_connection(err): + with self.assertRaises(socket.error) as cm: + self._make_mixin().open_socket() + self.assertIn('timed out', str(cm.exception)) + + def test_messageful_error_passes_through(self): + err = socket.error(errno.ECONNREFUSED, 'Connection refused') + with self._patch_create_connection(err): + with self.assertRaises(socket.error) as cm: + self._make_mixin().open_socket() + self.assertIs(cm.exception, err) + + +if __name__ == '__main__': + unittest.main() -- 2.39.5