diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 6b7c02f54e90af..3f95be31f4eed9 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -430,13 +430,30 @@ An :class:`IMAP4` instance has the following methods: .. method:: IMAP4.login_cram_md5(user, password) Force use of ``CRAM-MD5`` authentication when identifying the client to protect - the password. Will only work if the server ``CAPABILITY`` response includes the - phrase ``AUTH=CRAM-MD5``. + the password. It will only work if the server ``CAPABILITY`` response includes + the phrase ``AUTH=CRAM-MD5``. .. versionchanged:: 3.15 An :exc:`IMAP4.error` is raised if MD5 support is not available. +.. method:: IMAP4.login_plain(user, password) + + Authenticate using the ``PLAIN`` SASL mechanism (:rfc:`4616`). + + This is a plaintext authentication mechanism that can be used instead + of :meth:`login` when UTF-8 support is required (see :rfc:`6855`). + Since the credentials are only base64-encoded, not encrypted, this + method should only be used over a TLS-protected connection, such as + :class:`IMAP4_SSL` or after :meth:`starttls`. + + It will only work if the server supports the ``PLAIN`` mechanism, + which it need not advertise as ``AUTH=PLAIN`` in its ``CAPABILITY`` + response. + + .. versionadded:: next + + .. method:: IMAP4.logout() Shutdown connection to server. Returns server ``BYE`` response. diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index cf105a26d98d45..89ea6ff43e8cbd 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -235,6 +235,11 @@ imaplib a wrapper for the ``MOVE`` command (:rfc:`6851`). (Contributed by Serhiy Storchaka in :gh:`77508`.) +* Add the :meth:`~imaplib.IMAP4.login_plain` method, which authenticates + using the ``PLAIN`` SASL mechanism (:rfc:`4616`) and supports non-ASCII + user names and passwords. + (Contributed by Przemysław Buczkowski and Serhiy Storchaka in :gh:`89869`.) + logging ------- diff --git a/Lib/imaplib.py b/Lib/imaplib.py index adfd8afb9c053b..a8189f02947d74 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -739,6 +739,30 @@ def login(self, user, password): return typ, dat + def login_plain(self, user, password): + """Authenticate using the PLAIN SASL mechanism (RFC 4616). + + This is a plaintext authentication mechanism that can be used + instead of login() when UTF-8 support is required. Since the + credentials are only base64-encoded, not encrypted, it should + only be used over a TLS-protected connection. + + 'user' and 'password' can be strings (encoded to UTF-8) or + bytes-like objects. + """ + if isinstance(user, str): + user = user.encode('utf-8') + if isinstance(password, str): + password = password.encode('utf-8') + if b'\0' in user or b'\0' in password: + raise ValueError("NUL is not allowed in user name or password") + # An empty authorization identity (RFC 4616) makes the server + # derive it from the authentication identity; a non-empty one + # requests proxy authorization and is often rejected. + response = b'\0' + bytes(user) + b'\0' + bytes(password) + return self.authenticate("PLAIN", lambda _: response) + + def login_cram_md5(self, user, password): """ Force use of CRAM-MD5 authentication. diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 046d28f4d30c8a..b0f14fb848c26c 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -420,6 +420,15 @@ def cmd_AUTHENTICATE(self, tag, args): self._send_tagged(tag, 'NO', 'No access') +class AuthHandler_PLAIN(SimpleIMAPHandler): + capabilities = 'LOGINDISABLED AUTH=PLAIN' + def cmd_AUTHENTICATE(self, tag, args): + self.server.auth_args = args + self._send_textline('+') + self.server.response = yield + self._send_tagged(tag, 'OK', 'Logged in.') + + class NewIMAPTestsMixin: client = None @@ -692,6 +701,35 @@ def test_login_cram_md5_blocked(self): with self.assertRaisesRegex(imaplib.IMAP4.error, msg): client.login_cram_md5("tim", b"tanstaaftanstaaf") + def test_login_plain_ascii(self): + client, server = self._setup(AuthHandler_PLAIN) + self.assertIn('AUTH=PLAIN', client.capabilities) + ret, _ = client.login_plain("prem", "pass") + self.assertEqual(ret, "OK") + self.assertEqual(server.auth_args, ['PLAIN']) + self.assertEqual(server.response, b'AHByZW0AcGFzcw==\r\n') + + def test_login_plain_utf8(self): + client, server = self._setup(AuthHandler_PLAIN) + self.assertIn('AUTH=PLAIN', client.capabilities) + ret, _ = client.login_plain("pręm", "żółć") + self.assertEqual(ret, "OK") + self.assertEqual(server.response, b'AHByxJltAMW8w7PFgsSH\r\n') + + def test_login_plain_bytes(self): + # bytes are accepted and sent verbatim (not re-encoded). + client, server = self._setup(AuthHandler_PLAIN) + ret, _ = client.login_plain(b'pr\xc4\x99m', b'\xc5\xbc\xc3\xb3\xc5\x82\xc4\x87') + self.assertEqual(ret, "OK") + self.assertEqual(server.response, b'AHByxJltAMW8w7PFgsSH\r\n') + + def test_login_plain_nul(self): + client, _ = self._setup(AuthHandler_PLAIN) + with self.assertRaises(ValueError): + client.login_plain('user\0', 'pass') + with self.assertRaises(ValueError): + client.login_plain('user', b'pa\0ss') + def test_aborted_authentication(self): class MyServer(SimpleIMAPHandler): def cmd_AUTHENTICATE(self, tag, args): diff --git a/Misc/ACKS b/Misc/ACKS index 1f2049da56c2da..68fed1f68de593 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -260,6 +260,7 @@ Stan Bubrouski Brandt Bucher Curtis Bucher Colm Buckley +Przemysław Buczkowski Erik de Bueger Jan-Hein Bührman Marc Bürg diff --git a/Misc/NEWS.d/next/Library/2026-07-05-21-40-00.gh-issue-89869.XG7aHz.rst b/Misc/NEWS.d/next/Library/2026-07-05-21-40-00.gh-issue-89869.XG7aHz.rst new file mode 100644 index 00000000000000..4bf319f7f68152 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-05-21-40-00.gh-issue-89869.XG7aHz.rst @@ -0,0 +1,3 @@ +Add :meth:`imaplib.IMAP4.login_plain`, which authenticates using the +``PLAIN`` SASL mechanism (:rfc:`4616`). Unlike :meth:`~imaplib.IMAP4.login`, +it supports non-ASCII user names and passwords.