merge heads

This commit is contained in:
Benjamin Peterson 2015-05-10 21:20:01 -04:00
commit 0d905d4fcd
6 changed files with 180 additions and 22 deletions

View File

@ -77,7 +77,8 @@ Three exceptions are defined as attributes of the :class:`IMAP4` class:
There's also a subclass for secure connections: There's also a subclass for secure connections:
.. class:: IMAP4_SSL(host='', port=IMAP4_SSL_PORT, keyfile=None, certfile=None, ssl_context=None) .. class:: IMAP4_SSL(host='', port=IMAP4_SSL_PORT, keyfile=None, \
certfile=None, ssl_context=None)
This is a subclass derived from :class:`IMAP4` that connects over an SSL This is a subclass derived from :class:`IMAP4` that connects over an SSL
encrypted socket (to use this class you need a socket module that was compiled encrypted socket (to use this class you need a socket module that was compiled
@ -211,6 +212,10 @@ An :class:`IMAP4` instance has the following methods:
that will be base64 encoded and sent to the server. It should return that will be base64 encoded and sent to the server. It should return
``None`` if the client abort response ``*`` should be sent instead. ``None`` if the client abort response ``*`` should be sent instead.
.. versionchanged:: 3.5
string usernames and passwords are now encoded to ``utf-8`` instead of
being limited to ASCII.
.. method:: IMAP4.check() .. method:: IMAP4.check()
@ -243,6 +248,16 @@ An :class:`IMAP4` instance has the following methods:
Delete the ACLs (remove any rights) set for who on mailbox. Delete the ACLs (remove any rights) set for who on mailbox.
.. method:: IMAP4.enable(capability)
Enable *capability* (see :rfc:`5161`). Most capabilities do not need to be
enabled. Currently only the ``UTF8=ACCEPT`` capability is supported
(see :RFC:`6855`).
.. versionadded:: 3.5
The :meth:`enable` method itself, and :RFC:`6855` support.
.. method:: IMAP4.expunge() .. method:: IMAP4.expunge()
Permanently remove deleted items from selected mailbox. Generates an ``EXPUNGE`` Permanently remove deleted items from selected mailbox. Generates an ``EXPUNGE``
@ -380,7 +395,9 @@ An :class:`IMAP4` instance has the following methods:
Search mailbox for matching messages. *charset* may be ``None``, in which case Search mailbox for matching messages. *charset* may be ``None``, in which case
no ``CHARSET`` will be specified in the request to the server. The IMAP no ``CHARSET`` will be specified in the request to the server. The IMAP
protocol requires that at least one criterion be specified; an exception will be protocol requires that at least one criterion be specified; an exception will be
raised when the server returns an error. raised when the server returns an error. *charset* must be ``None`` if
the ``UTF8=ACCEPT`` capability was enabled using the :meth:`enable`
command.
Example:: Example::
@ -542,6 +559,15 @@ The following attributes are defined on instances of :class:`IMAP4`:
the module variable ``Debug``. Values greater than three trace each command. the module variable ``Debug``. Values greater than three trace each command.
.. attribute:: IMAP4.utf8_enabled
Boolean value that is normally ``False``, but is set to ``True`` if an
:meth:`enable` command is successfully issued for the ``UTF8=ACCEPT``
capability.
.. versionadded:: 3.5
.. _imap4-example: .. _imap4-example:
IMAP4 Example IMAP4 Example

View File

@ -337,6 +337,17 @@ imaplib
automatically at the end of the block. (Contributed by Tarek Ziadé and automatically at the end of the block. (Contributed by Tarek Ziadé and
Serhiy Storchaka in :issue:`4972`.) Serhiy Storchaka in :issue:`4972`.)
* :mod:`imaplib` now supports :rfc:`5161`: the :meth:`~imaplib.IMAP4.enable`
extension), and :rfc:`6855`: utf-8 support (internationalized email, via the
``UTF8=ACCEPT`` argument to :meth:`~imaplib.IMAP4.enable`). A new attribute,
:attr:`~imaplib.IMAP4.utf8_enabled`, tracks whether or not :rfc:`6855`
support is enabled. Milan Oberkirch, R. David Murray, and Maciej Szulik in
:issue:`21800`.)
* :mod:`imaplib` now automatically encodes non-ASCII string usernames and
passwords using ``UTF8``, as recommended by the RFCs. (Contributed by Milan
Oberkirch in :issue:`21800`.)
imghdr imghdr
------ ------

View File

@ -66,6 +66,7 @@ Commands = {
'CREATE': ('AUTH', 'SELECTED'), 'CREATE': ('AUTH', 'SELECTED'),
'DELETE': ('AUTH', 'SELECTED'), 'DELETE': ('AUTH', 'SELECTED'),
'DELETEACL': ('AUTH', 'SELECTED'), 'DELETEACL': ('AUTH', 'SELECTED'),
'ENABLE': ('AUTH', ),
'EXAMINE': ('AUTH', 'SELECTED'), 'EXAMINE': ('AUTH', 'SELECTED'),
'EXPUNGE': ('SELECTED',), 'EXPUNGE': ('SELECTED',),
'FETCH': ('SELECTED',), 'FETCH': ('SELECTED',),
@ -107,12 +108,17 @@ InternalDate = re.compile(br'.*INTERNALDATE "'
br' (?P<hour>[0-9][0-9]):(?P<min>[0-9][0-9]):(?P<sec>[0-9][0-9])' br' (?P<hour>[0-9][0-9]):(?P<min>[0-9][0-9]):(?P<sec>[0-9][0-9])'
br' (?P<zonen>[-+])(?P<zoneh>[0-9][0-9])(?P<zonem>[0-9][0-9])' br' (?P<zonen>[-+])(?P<zoneh>[0-9][0-9])(?P<zonem>[0-9][0-9])'
br'"') br'"')
# Literal is no longer used; kept for backward compatibility.
Literal = re.compile(br'.*{(?P<size>\d+)}$', re.ASCII) Literal = re.compile(br'.*{(?P<size>\d+)}$', re.ASCII)
MapCRLF = re.compile(br'\r\n|\r|\n') MapCRLF = re.compile(br'\r\n|\r|\n')
Response_code = re.compile(br'\[(?P<type>[A-Z-]+)( (?P<data>[^\]]*))?\]') Response_code = re.compile(br'\[(?P<type>[A-Z-]+)( (?P<data>[^\]]*))?\]')
Untagged_response = re.compile(br'\* (?P<type>[A-Z-]+)( (?P<data>.*))?') Untagged_response = re.compile(br'\* (?P<type>[A-Z-]+)( (?P<data>.*))?')
# Untagged_status is no longer used; kept for backward compatibility
Untagged_status = re.compile( Untagged_status = re.compile(
br'\* (?P<data>\d+) (?P<type>[A-Z-]+)( (?P<data2>.*))?', re.ASCII) br'\* (?P<data>\d+) (?P<type>[A-Z-]+)( (?P<data2>.*))?', re.ASCII)
# We compile these in _mode_xxx.
_Literal = br'.*{(?P<size>\d+)}$'
_Untagged_status = br'\* (?P<data>\d+) (?P<type>[A-Z-]+)( (?P<data2>.*))?'
@ -176,6 +182,7 @@ class IMAP4:
self.is_readonly = False # READ-ONLY desired state self.is_readonly = False # READ-ONLY desired state
self.tagnum = 0 self.tagnum = 0
self._tls_established = False self._tls_established = False
self._mode_ascii()
# Open socket to server. # Open socket to server.
@ -190,6 +197,19 @@ class IMAP4:
pass pass
raise raise
def _mode_ascii(self):
self.utf8_enabled = False
self._encoding = 'ascii'
self.Literal = re.compile(_Literal, re.ASCII)
self.Untagged_status = re.compile(_Untagged_status, re.ASCII)
def _mode_utf8(self):
self.utf8_enabled = True
self._encoding = 'utf-8'
self.Literal = re.compile(_Literal)
self.Untagged_status = re.compile(_Untagged_status)
def _connect(self): def _connect(self):
# Create unique tag for this session, # Create unique tag for this session,
@ -360,7 +380,10 @@ class IMAP4:
date_time = Time2Internaldate(date_time) date_time = Time2Internaldate(date_time)
else: else:
date_time = None date_time = None
self.literal = MapCRLF.sub(CRLF, message) literal = MapCRLF.sub(CRLF, message)
if self.utf8_enabled:
literal = b'UTF8 (' + literal + b')'
self.literal = literal
return self._simple_command(name, mailbox, flags, date_time) return self._simple_command(name, mailbox, flags, date_time)
@ -455,6 +478,18 @@ class IMAP4:
""" """
return self._simple_command('DELETEACL', mailbox, who) return self._simple_command('DELETEACL', mailbox, who)
def enable(self, capability):
"""Send an RFC5161 enable string to the server.
(typ, [data]) = <intance>.enable(capability)
"""
if 'ENABLE' not in self.capabilities:
raise IMAP4.error("Server does not support ENABLE")
typ, data = self._simple_command('ENABLE', capability)
if typ == 'OK' and 'UTF8=ACCEPT' in capability.upper():
self._mode_utf8()
return typ, data
def expunge(self): def expunge(self):
"""Permanently remove deleted items from selected mailbox. """Permanently remove deleted items from selected mailbox.
@ -561,7 +596,7 @@ class IMAP4:
def _CRAM_MD5_AUTH(self, challenge): def _CRAM_MD5_AUTH(self, challenge):
""" Authobject to use with CRAM-MD5 authentication. """ """ Authobject to use with CRAM-MD5 authentication. """
import hmac import hmac
pwd = (self.password.encode('ASCII') if isinstance(self.password, str) pwd = (self.password.encode('utf-8') if isinstance(self.password, str)
else self.password) else self.password)
return self.user + " " + hmac.HMAC(pwd, challenge, 'md5').hexdigest() return self.user + " " + hmac.HMAC(pwd, challenge, 'md5').hexdigest()
@ -661,9 +696,12 @@ class IMAP4:
(typ, [data]) = <instance>.search(charset, criterion, ...) (typ, [data]) = <instance>.search(charset, criterion, ...)
'data' is space separated list of matching message numbers. 'data' is space separated list of matching message numbers.
If UTF8 is enabled, charset MUST be None.
""" """
name = 'SEARCH' name = 'SEARCH'
if charset: if charset:
if self.utf8_enabled:
raise IMAP4.error("Non-None charset not valid in UTF8 mode")
typ, dat = self._simple_command(name, 'CHARSET', charset, *criteria) typ, dat = self._simple_command(name, 'CHARSET', charset, *criteria)
else: else:
typ, dat = self._simple_command(name, *criteria) typ, dat = self._simple_command(name, *criteria)
@ -877,7 +915,7 @@ class IMAP4:
def _check_bye(self): def _check_bye(self):
bye = self.untagged_responses.get('BYE') bye = self.untagged_responses.get('BYE')
if bye: if bye:
raise self.abort(bye[-1].decode('ascii', 'replace')) raise self.abort(bye[-1].decode(self._encoding, 'replace'))
def _command(self, name, *args): def _command(self, name, *args):
@ -898,12 +936,12 @@ class IMAP4:
raise self.readonly('mailbox status changed to READ-ONLY') raise self.readonly('mailbox status changed to READ-ONLY')
tag = self._new_tag() tag = self._new_tag()
name = bytes(name, 'ASCII') name = bytes(name, self._encoding)
data = tag + b' ' + name data = tag + b' ' + name
for arg in args: for arg in args:
if arg is None: continue if arg is None: continue
if isinstance(arg, str): if isinstance(arg, str):
arg = bytes(arg, "ASCII") arg = bytes(arg, self._encoding)
data = data + b' ' + arg data = data + b' ' + arg
literal = self.literal literal = self.literal
@ -913,7 +951,7 @@ class IMAP4:
literator = literal literator = literal
else: else:
literator = None literator = None
data = data + bytes(' {%s}' % len(literal), 'ASCII') data = data + bytes(' {%s}' % len(literal), self._encoding)
if __debug__: if __debug__:
if self.debug >= 4: if self.debug >= 4:
@ -978,7 +1016,7 @@ class IMAP4:
typ, dat = self.capability() typ, dat = self.capability()
if dat == [None]: if dat == [None]:
raise self.error('no CAPABILITY response from server') raise self.error('no CAPABILITY response from server')
dat = str(dat[-1], "ASCII") dat = str(dat[-1], self._encoding)
dat = dat.upper() dat = dat.upper()
self.capabilities = tuple(dat.split()) self.capabilities = tuple(dat.split())
@ -997,10 +1035,10 @@ class IMAP4:
if self._match(self.tagre, resp): if self._match(self.tagre, resp):
tag = self.mo.group('tag') tag = self.mo.group('tag')
if not tag in self.tagged_commands: if not tag in self.tagged_commands:
raise self.abort('unexpected tagged response: %s' % resp) raise self.abort('unexpected tagged response: %r' % resp)
typ = self.mo.group('type') typ = self.mo.group('type')
typ = str(typ, 'ASCII') typ = str(typ, self._encoding)
dat = self.mo.group('data') dat = self.mo.group('data')
self.tagged_commands[tag] = (typ, [dat]) self.tagged_commands[tag] = (typ, [dat])
else: else:
@ -1009,7 +1047,7 @@ class IMAP4:
# '*' (untagged) responses? # '*' (untagged) responses?
if not self._match(Untagged_response, resp): if not self._match(Untagged_response, resp):
if self._match(Untagged_status, resp): if self._match(self.Untagged_status, resp):
dat2 = self.mo.group('data2') dat2 = self.mo.group('data2')
if self.mo is None: if self.mo is None:
@ -1019,17 +1057,17 @@ class IMAP4:
self.continuation_response = self.mo.group('data') self.continuation_response = self.mo.group('data')
return None # NB: indicates continuation return None # NB: indicates continuation
raise self.abort("unexpected response: '%s'" % resp) raise self.abort("unexpected response: %r" % resp)
typ = self.mo.group('type') typ = self.mo.group('type')
typ = str(typ, 'ascii') typ = str(typ, self._encoding)
dat = self.mo.group('data') dat = self.mo.group('data')
if dat is None: dat = b'' # Null untagged response if dat is None: dat = b'' # Null untagged response
if dat2: dat = dat + b' ' + dat2 if dat2: dat = dat + b' ' + dat2
# Is there a literal to come? # Is there a literal to come?
while self._match(Literal, dat): while self._match(self.Literal, dat):
# Read literal direct from connection. # Read literal direct from connection.
@ -1053,7 +1091,7 @@ class IMAP4:
if typ in ('OK', 'NO', 'BAD') and self._match(Response_code, dat): if typ in ('OK', 'NO', 'BAD') and self._match(Response_code, dat):
typ = self.mo.group('type') typ = self.mo.group('type')
typ = str(typ, "ASCII") typ = str(typ, self._encoding)
self._append_untagged(typ, self.mo.group('data')) self._append_untagged(typ, self.mo.group('data'))
if __debug__: if __debug__:
@ -1123,7 +1161,7 @@ class IMAP4:
def _new_tag(self): def _new_tag(self):
tag = self.tagpre + bytes(str(self.tagnum), 'ASCII') tag = self.tagpre + bytes(str(self.tagnum), self._encoding)
self.tagnum = self.tagnum + 1 self.tagnum = self.tagnum + 1
self.tagged_commands[tag] = None self.tagged_commands[tag] = None
return tag return tag
@ -1213,7 +1251,8 @@ if HAVE_SSL:
""" """
def __init__(self, host='', port=IMAP4_SSL_PORT, keyfile=None, certfile=None, ssl_context=None): def __init__(self, host='', port=IMAP4_SSL_PORT, keyfile=None,
certfile=None, ssl_context=None):
if ssl_context is not None and keyfile is not None: if ssl_context is not None and keyfile is not None:
raise ValueError("ssl_context and keyfile arguments are mutually " raise ValueError("ssl_context and keyfile arguments are mutually "
"exclusive") "exclusive")
@ -1251,7 +1290,7 @@ class IMAP4_stream(IMAP4):
Instantiate with: IMAP4_stream(command) Instantiate with: IMAP4_stream(command)
where "command" is a string that can be passed to subprocess.Popen() "command" - a string that can be passed to subprocess.Popen()
for more documentation see the docstring of the parent class IMAP4. for more documentation see the docstring of the parent class IMAP4.
""" """
@ -1328,7 +1367,7 @@ class _Authenticator:
# #
oup = b'' oup = b''
if isinstance(inp, str): if isinstance(inp, str):
inp = inp.encode('ASCII') inp = inp.encode('utf-8')
while inp: while inp:
if len(inp) > 48: if len(inp) > 48:
t = inp[:48] t = inp[:48]

View File

@ -265,6 +265,84 @@ class ThreadedNetworkedTests(unittest.TestCase):
self.assertRaises(imaplib.IMAP4.abort, self.assertRaises(imaplib.IMAP4.abort,
self.imap_class, *server.server_address) self.imap_class, *server.server_address)
class UTF8Server(SimpleIMAPHandler):
capabilities = 'AUTH ENABLE UTF8=ACCEPT'
def cmd_ENABLE(self, tag, args):
self._send_tagged(tag, 'OK', 'ENABLE successful')
def cmd_AUTHENTICATE(self, tag, args):
self._send_textline('+')
self.server.response = yield
self._send_tagged(tag, 'OK', 'FAKEAUTH successful')
@reap_threads
def test_enable_raises_error_if_not_AUTH(self):
with self.reaped_pair(self.UTF8Server) as (server, client):
self.assertFalse(client.utf8_enabled)
self.assertRaises(imaplib.IMAP4.error, client.enable, 'foo')
self.assertFalse(client.utf8_enabled)
# XXX Also need a test that enable after SELECT raises an error.
@reap_threads
def test_enable_raises_error_if_no_capability(self):
class NoEnableServer(self.UTF8Server):
capabilities = 'AUTH'
with self.reaped_pair(NoEnableServer) as (server, client):
self.assertRaises(imaplib.IMAP4.error, client.enable, 'foo')
@reap_threads
def test_enable_UTF8_raises_error_if_not_supported(self):
class NonUTF8Server(SimpleIMAPHandler):
pass
with self.assertRaises(imaplib.IMAP4.error):
with self.reaped_pair(NonUTF8Server) as (server, client):
typ, data = client.login('user', 'pass')
self.assertEqual(typ, 'OK')
client.enable('UTF8=ACCEPT')
pass
@reap_threads
def test_enable_UTF8_True_append(self):
class UTF8AppendServer(self.UTF8Server):
def cmd_APPEND(self, tag, args):
self._send_textline('+')
self.server.response = yield
self._send_tagged(tag, 'OK', 'okay')
with self.reaped_pair(UTF8AppendServer) as (server, client):
self.assertEqual(client._encoding, 'ascii')
code, _ = client.authenticate('MYAUTH', lambda x: b'fake')
self.assertEqual(code, 'OK')
self.assertEqual(server.response,
b'ZmFrZQ==\r\n') # b64 encoded 'fake'
code, _ = client.enable('UTF8=ACCEPT')
self.assertEqual(code, 'OK')
self.assertEqual(client._encoding, 'utf-8')
msg_string = 'Subject: üñí©öðé'
typ, data = client.append(
None, None, None, msg_string.encode('utf-8'))
self.assertEqual(typ, 'OK')
self.assertEqual(
server.response,
('UTF8 (%s)\r\n' % msg_string).encode('utf-8')
)
# XXX also need a test that makes sure that the Literal and Untagged_status
# regexes uses unicode in UTF8 mode instead of the default ASCII.
@reap_threads
def test_search_disallows_charset_in_utf8_mode(self):
with self.reaped_pair(self.UTF8Server) as (server, client):
typ, _ = client.authenticate('MYAUTH', lambda x: b'fake')
self.assertEqual(typ, 'OK')
typ, _ = client.enable('UTF8=ACCEPT')
self.assertEqual(typ, 'OK')
self.assertTrue(client.utf8_enabled)
self.assertRaises(imaplib.IMAP4.error, client.search, 'foo', 'bar')
@reap_threads @reap_threads
def test_bad_auth_name(self): def test_bad_auth_name(self):

View File

@ -38,6 +38,10 @@ Core and Builtins
Library Library
------- -------
- Issue #21800: imaplib now supports RFC 5161 (enable), RFC 6855
(utf8/internationalized email) and automatically encodes non-ASCII
usernames and passwords to UTF8.
- Issue #24134: assertRaises(), assertRaisesRegex(), assertWarns() and - Issue #24134: assertRaises(), assertRaisesRegex(), assertWarns() and
assertWarnsRegex() checks are not longer successful if the callable is None. assertWarnsRegex() checks are not longer successful if the callable is None.

View File

@ -149,9 +149,9 @@ gen_send_ex(PyGenObject *gen, PyObject *arg, int exc)
"generator raised StopIteration"); "generator raised StopIteration");
PyErr_Fetch(&exc, &val2, &tb); PyErr_Fetch(&exc, &val2, &tb);
PyErr_NormalizeException(&exc, &val2, &tb); PyErr_NormalizeException(&exc, &val2, &tb);
Py_INCREF(val);
PyException_SetCause(val2, val); PyException_SetCause(val2, val);
PyException_SetContext(val2, val); PyException_SetContext(val2, val);
Py_INCREF(val);
PyErr_Restore(exc, val2, tb); PyErr_Restore(exc, val2, tb);
} }
} }