summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPatrick Totzke <patricktotzke@gmail.com>2017-09-02 08:35:01 +0100
committerGitHub <noreply@github.com>2017-09-02 08:35:01 +0100
commit668925817cd8aeed7ef9926969d49c4586c26adf (patch)
tree0cd16d2279a971d0b5d650f03ee3f6ce0e592852
parent3a3898f2ce976fbb800f55764675c4962e7adf28 (diff)
parentc09196eb92af61ce0efa2f2ea47f42856ef87ac9 (diff)
Merge branch 'master' into fix/spelling
-rw-r--r--.codeclimate.yml6
-rw-r--r--alot/account.py149
-rw-r--r--alot/addressbook/abook.py3
-rw-r--r--alot/commands/__init__.py1
-rw-r--r--alot/commands/envelope.py9
-rw-r--r--alot/commands/globals.py9
-rw-r--r--alot/commands/thread.py6
-rw-r--r--alot/crypto.py44
-rw-r--r--alot/db/manager.py3
-rw-r--r--alot/db/message.py9
-rw-r--r--alot/db/utils.py29
-rw-r--r--alot/defaults/alot.rc.spec8
-rw-r--r--alot/settings/manager.py12
-rw-r--r--alot/settings/utils.py1
-rw-r--r--alot/utils/cached_property.py48
-rw-r--r--alot/widgets/globals.py4
-rw-r--r--alot/widgets/search.py5
-rw-r--r--docs/source/api/settings.rst2
-rw-r--r--docs/source/configuration/accounts_table15
-rw-r--r--tests/account_test.py111
-rw-r--r--tests/commands/thread_test.py78
-rw-r--r--tests/db/utils_test.py168
-rw-r--r--tests/settings/manager_test.py17
23 files changed, 598 insertions, 139 deletions
diff --git a/.codeclimate.yml b/.codeclimate.yml
index 401fc63b..027bb0dd 100644
--- a/.codeclimate.yml
+++ b/.codeclimate.yml
@@ -1,10 +1,6 @@
----
engines:
- duplication:
+ pep8:
enabled: true
- config:
- languages:
- - python
fixme:
enabled: true
radon:
diff --git a/alot/account.py b/alot/account.py
index 30f0e8da..a224c109 100644
--- a/alot/account.py
+++ b/alot/account.py
@@ -1,4 +1,6 @@
+# encoding=utf-8
# Copyright (C) 2011-2012 Patrick Totzke <patricktotzke@gmail.com>
+# Copyright © 2017 Dylan Baker
# This file is released under the GNU GPL, version 3 or a later revision.
# For further details see the COPYING file
from __future__ import absolute_import
@@ -7,12 +9,152 @@ import abc
import glob
import logging
import mailbox
+import operator
import os
from .helper import call_cmd_async
from .helper import split_commandstring
+class Address(object):
+
+ """A class that represents an email address.
+
+ This class implements a number of RFC requirements (as explained in detail
+ below) specifically in the comparison of email addresses to each other.
+
+ This class abstracts the requirements of RFC 5321 § 2.4 on the user name
+ portion of the email:
+
+ local-part of a mailbox MUST BE treated as case sensitive. Therefore,
+ SMTP implementations MUST take care to preserve the case of mailbox
+ local-parts. In particular, for some hosts, the user "smith" is
+ different from the user "Smith". However, exploiting the case
+ sensitivity of mailbox local-parts impedes interoperability and is
+ discouraged. Mailbox domains follow normal DNS rules and are hence not
+ case sensitive.
+
+ This is complicated by § 2.3.11 of the same RFC:
+
+ The standard mailbox naming convention is defined to be
+ "local-part@domain"; contemporary usage permits a much broader set of
+ applications than simple "user names". Consequently, and due to a long
+ history of problems when intermediate hosts have attempted to optimize
+ transport by modifying them, the local-part MUST be interpreted and
+ assigned semantics only by the host specified in the domain part of the
+ address.
+
+ And also the restrictions that RFC 1035 § 3.1 places on the domain name:
+
+ Name servers and resolvers must compare [domains] in a case-insensitive
+ manner
+
+ Because of RFC 6531 § 3.2, we take special care to ensure that unicode
+ names will work correctly:
+
+ An SMTP server that announces the SMTPUTF8 extension MUST be prepared
+ to accept a UTF-8 string [RFC3629] in any position in which RFC 5321
+ specifies that a <mailbox> can appear. Although the characters in the
+ <local-part> are permitted to contain non-ASCII characters, the actual
+ parsing of the <local-part> and the delimiters used are unchanged from
+ the base email specification [RFC5321]
+
+ What this means is that the username can be either case-insensitive or not,
+ but only the receiving SMTP server can know what it's own rules are. The
+ consensus is that the vast majority (all?) of the SMTP servers in modern
+ usage treat user names as case-insensitve. Therefore we also, by default,
+ treat the user name as case insenstive.
+
+ :param unicode user: The "user name" portion of the address.
+ :param unicode domain: The domain name portion of the address.
+ :param bool case_sensitive: If False (the default) the user name portion of
+ the address will be compared to the other user name portion without
+ regard to case. If True then it will.
+ """
+
+ def __init__(self, user, domain, case_sensitive=False):
+ assert isinstance(user, unicode), 'Username must be unicode'
+ assert isinstance(domain, unicode), 'Domain name must be unicode'
+ self.username = user
+ self.domainname = domain
+ self.case_sensitive = case_sensitive
+
+ @classmethod
+ def from_string(cls, address, case_sensitive=False):
+ """Alternate constructor for building from a string.
+
+ :param unicode address: An email address in <user>@<domain> form
+ :param bool case_sensitive: passed directly to the constructor argument
+ of the same name.
+ :returns: An account from the given arguments
+ :rtype: :class:`Account`
+ """
+ assert isinstance(address, unicode), 'address must be unicode'
+ username, domainname = address.split(u'@')
+ return cls(username, domainname, case_sensitive=case_sensitive)
+
+ def __repr__(self):
+ return u'Address({!r}, {!r}, case_sensitive={})'.format(
+ self.username,
+ self.domainname,
+ unicode(self.case_sensitive))
+
+ def __unicode__(self):
+ return u'{}@{}'.format(self.username, self.domainname)
+
+ def __str__(self):
+ return u'{}@{}'.format(self.username, self.domainname).encode('utf-8')
+
+ def __cmp(self, other, comparitor):
+ """Shared helper for rich comparison operators.
+
+ This allows the comparison operators to be relatively simple and share
+ the complex logic.
+
+ If the username is not considered case sensitive then lower the
+ username of both self and the other, and handle that the other can be
+ either another :class:`~alot.account.Address`, or a `unicode` instance.
+
+ :param other: The other address to compare against
+ :type other: unicode or ~alot.account.Address
+ :param callable comparitor: A function with the a signature
+ (unicode, unicode) -> bool that will compare the two instance.
+ The intention is to use functions from the operator module.
+ """
+ if isinstance(other, unicode):
+ ouser, odomain = other.split(u'@')
+ elif isinstance(other, str):
+ ouser, odomain = other.decode('utf-8').split(u'@')
+ else:
+ ouser = other.username
+ odomain = other.domainname
+
+ if not self.case_sensitive:
+ ouser = ouser.lower()
+ username = self.username.lower()
+ else:
+ username = self.username
+
+ return (comparitor(username, ouser) and
+ comparitor(self.domainname.lower(), odomain.lower()))
+
+ def __eq__(self, other):
+ if not isinstance(other, (Address, basestring)):
+ raise TypeError('Address must be compared to Address or basestring')
+ return self.__cmp(other, operator.eq)
+
+ def __ne__(self, other):
+ if not isinstance(other, (Address, basestring)):
+ raise TypeError('Address must be compared to Address or basestring')
+ # != is the only rich comparitor that cannot be implemented using 'and'
+ # in self.__cmp, so it's implemented as not ==.
+ return not self.__cmp(other, operator.eq)
+
+ def __hash__(self):
+ return hash((self.username.lower(), self.domainname.lower(),
+ self.case_sensitive))
+
+
class SendingMailFailed(RuntimeError):
pass
@@ -59,7 +201,7 @@ class Account(object):
signature_filename=None, signature_as_attachment=False,
sent_box=None, sent_tags=None, draft_box=None,
draft_tags=None, abook=None, sign_by_default=False,
- encrypt_by_default=u"none",
+ encrypt_by_default=u"none", case_sensitive_username=False,
**_):
sent_tags = sent_tags or []
if 'sent' not in sent_tags:
@@ -68,8 +210,9 @@ class Account(object):
if 'draft' not in draft_tags:
draft_tags.append('draft')
- self.address = address
- self.aliases = aliases or []
+ self.address = Address.from_string(address, case_sensitive=case_sensitive_username)
+ self.aliases = [Address.from_string(a, case_sensitive=case_sensitive_username)
+ for a in (aliases or [])]
self.alias_regexp = alias_regexp
self.realname = realname
self.gpg_key = gpg_key
diff --git a/alot/addressbook/abook.py b/alot/addressbook/abook.py
index b620fbac..41b5103e 100644
--- a/alot/addressbook/abook.py
+++ b/alot/addressbook/abook.py
@@ -16,7 +16,8 @@ class AbookAddressBook(AddressBook):
:type path: str
"""
AddressBook.__init__(self, **kwargs)
- DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..', 'defaults')
+ DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..',
+ 'defaults')
self._spec = os.path.join(DEFAULTSPATH, 'abook_contacts.spec')
path = os.path.expanduser(path)
self._config = read_config(path, self._spec)
diff --git a/alot/commands/__init__.py b/alot/commands/__init__.py
index 0638926a..bcd27c07 100644
--- a/alot/commands/__init__.py
+++ b/alot/commands/__init__.py
@@ -34,6 +34,7 @@ class CommandCanceled(Exception):
"""
pass
+
COMMANDS = {
'search': {},
'envelope': {},
diff --git a/alot/commands/envelope.py b/alot/commands/envelope.py
index 9681a020..f497ecc2 100644
--- a/alot/commands/envelope.py
+++ b/alot/commands/envelope.py
@@ -124,9 +124,8 @@ class SaveCommand(Command):
return
if account.draft_box is None:
- ui.notify(
- 'abort: account <%s> has no draft_box set.' % envelope.get('From'),
- priority='error')
+ msg = 'abort: Account for {} has no draft_box'
+ ui.notify(msg.format(account.address), priority='error')
return
mail = envelope.construct_mail()
@@ -511,8 +510,8 @@ class SignCommand(Command):
return
if not acc.gpg_key:
envelope.sign = False
- ui.notify('Account for {} has no gpg key'.format(acc.address),
- priority='error')
+ msg = 'Account for {} has no gpg key'
+ ui.notify(msg.format(acc.address), priority='error')
return
envelope.sign_key = acc.gpg_key
else:
diff --git a/alot/commands/globals.py b/alot/commands/globals.py
index 565bd742..57ddfcb6 100644
--- a/alot/commands/globals.py
+++ b/alot/commands/globals.py
@@ -78,7 +78,8 @@ class ExitCommand(Command):
if ui.db_was_locked:
msg = 'Database locked. Exit without saving?'
- if (yield ui.choice(msg, msg_position='left', cancel='no')) == 'no':
+ response = yield ui.choice(msg, msg_position='left', cancel='no')
+ if response == 'no':
return
ui.exit()
@@ -855,7 +856,8 @@ class ComposeCommand(Command):
self.envelope.sign = account.sign_by_default
self.envelope.sign_key = account.gpg_key
else:
- msg = 'Cannot find gpg key for account {}'.format(account.address)
+ msg = 'Cannot find gpg key for account {}'
+ msg = msg.format(account.address)
logging.warning(msg)
ui.notify(msg, priority='error')
@@ -910,7 +912,8 @@ class ComposeCommand(Command):
logging.debug("Trying to encrypt message because "
"account.encrypt_by_default=%s",
account.encrypt_by_default)
- yield set_encrypt(ui, self.envelope, block_error=self.encrypt, signed_only=True)
+ yield set_encrypt(ui, self.envelope, block_error=self.encrypt,
+ signed_only=True)
else:
logging.debug("No encryption by default, encrypt_by_default=%s",
account.encrypt_by_default)
diff --git a/alot/commands/thread.py b/alot/commands/thread.py
index 4c04664f..b0eff761 100644
--- a/alot/commands/thread.py
+++ b/alot/commands/thread.py
@@ -71,11 +71,13 @@ def determine_sender(mail, action='reply'):
# pick the most important account that has an address in candidates
# and use that accounts realname and the address found here
for account in my_accounts:
- acc_addresses = [re.escape(a) for a in account.get_addresses()]
+ acc_addresses = [re.escape(unicode(a)) for a in account.get_addresses()]
if account.alias_regexp is not None:
acc_addresses.append(account.alias_regexp)
for alias in acc_addresses:
- regex = re.compile('^' + alias + '$', flags=re.IGNORECASE)
+ regex = re.compile(
+ u'^' + unicode(alias) + u'$',
+ flags=re.IGNORECASE if not account.address.case_sensitive else 0)
for seen_name, seen_address in candidate_addresses:
if regex.match(seen_address):
logging.debug("match!: '%s' '%s'", seen_address, alias)
diff --git a/alot/crypto.py b/alot/crypto.py
index 52eb8c58..6e3e8fa6 100644
--- a/alot/crypto.py
+++ b/alot/crypto.py
@@ -81,31 +81,22 @@ def get_key(keyid, validate=False, encrypt=False, sign=False,
valid_key = None
- # Catching exceptions for list_keys
- try:
- for k in list_keys(hint=keyid):
- try:
- validate_key(k, encrypt=encrypt, sign=sign)
- except GPGProblem:
- # if the key is invalid for given action skip it
- continue
-
- if valid_key:
- # we have already found one valid key and now we find
- # another? We really received an ambiguous keyid
- raise GPGProblem(
- "More than one key found matching this filter. "
- "Please be more specific "
- "(use a key ID like 4AC8EE1D).",
- code=GPGCode.AMBIGUOUS_NAME)
- valid_key = k
- except gpg.errors.GPGMEError as e:
- # This if will be triggered if there is no key matching at all.
- if e.getcode() == gpg.errors.AMBIGUOUS_NAME:
+ for k in list_keys(hint=keyid):
+ try:
+ validate_key(k, encrypt=encrypt, sign=sign)
+ except GPGProblem:
+ # if the key is invalid for given action skip it
+ continue
+
+ if valid_key:
+ # we have already found one valid key and now we find
+ # another? We really received an ambiguous keyid
raise GPGProblem(
- 'Can not find any key for "{}".'.format(keyid),
- code=GPGCode.NOT_FOUND)
- raise
+ "More than one key found matching this filter. "
+ "Please be more specific "
+ "(use a key ID like 4AC8EE1D).",
+ code=GPGCode.AMBIGUOUS_NAME)
+ valid_key = k
if not valid_key:
# there were multiple keys found but none of them are valid for
@@ -120,7 +111,7 @@ def get_key(keyid, validate=False, encrypt=False, sign=False,
'Can not find usable key for "{}".'.format(keyid),
code=GPGCode.NOT_FOUND)
else:
- raise e
+ raise e # pragma: nocover
if signed_only and not check_uid_validity(key, keyid):
raise GPGProblem('Cannot find a trusworthy key for "{}".'.format(keyid),
code=GPGCode.NOT_FOUND)
@@ -161,7 +152,8 @@ def detached_signature_for(plaintext_str, keys):
"""
ctx = gpg.core.Context(armor=True)
ctx.signers = keys
- (sigblob, sign_result) = ctx.sign(plaintext_str, mode=gpg.constants.SIG_MODE_DETACH)
+ (sigblob, sign_result) = ctx.sign(plaintext_str,
+ mode=gpg.constants.SIG_MODE_DETACH)
return sign_result.signatures, sigblob
diff --git a/alot/db/manager.py b/alot/db/manager.py
index d71812d1..dfd681d0 100644
--- a/alot/db/manager.py
+++ b/alot/db/manager.py
@@ -377,7 +377,8 @@ class DBManager(object):
:param sort: Sort order. one of ['oldest_first', 'newest_first',
'message_id', 'unsorted']
:type query: str
- :param exclude_tags: Tags to exclude by default unless included in the search
+ :param exclude_tags: Tags to exclude by default unless included in the
+ search
:type exclude_tags: list of str
:returns: a pipe together with the process that asynchronously
writes to it.
diff --git a/alot/db/message.py b/alot/db/message.py
index f8dcb74f..9d7437eb 100644
--- a/alot/db/message.py
+++ b/alot/db/message.py
@@ -66,17 +66,17 @@ class Message(object):
def __eq__(self, other):
if isinstance(other, type(self)):
- return self.get_message_id() == other.get_message_id()
+ return self._id == other.get_message_id()
return NotImplemented
def __ne__(self, other):
if isinstance(other, type(self)):
- return self.get_message_id() != other.get_message_id()
+ return self._id != other.get_message_id()
return NotImplemented
def __lt__(self, other):
if isinstance(other, type(self)):
- return self.get_message_id() < other.get_message_id()
+ return self._id < other.get_message_id()
return NotImplemented
def get_email(self):
@@ -116,8 +116,7 @@ class Message(object):
def get_tags(self):
"""returns tags attached to this message as list of strings"""
- l = sorted(self._tags)
- return l
+ return sorted(self._tags)
def get_thread(self):
"""returns the :class:`~alot.db.Thread` this msg belongs to"""
diff --git a/alot/db/utils.py b/alot/db/utils.py
index 2db850ce..379fd6a2 100644
--- a/alot/db/utils.py
+++ b/alot/db/utils.py
@@ -45,6 +45,8 @@ def add_signature_headers(mail, sigs, error_msg):
string indicating no error
'''
sig_from = u''
+ sig_known = True
+ uid_trusted = False
if isinstance(error_msg, str):
error_msg = error_msg.decode('utf-8')
@@ -60,13 +62,11 @@ def add_signature_headers(mail, sigs, error_msg):
uid_trusted = True
break
else:
- # No trusted uid found, we did not break but drop from the
- # for loop.
- uid_trusted = False
+ # No trusted uid found, since we did not break from the loop.
sig_from = key.uids[0].uid.decode('utf-8')
- except:
+ except GPGProblem:
sig_from = sigs[0].fpr.decode('utf-8')
- uid_trusted = False
+ sig_known = False
if error_msg:
msg = u'Invalid: {}'.format(error_msg)
@@ -75,7 +75,8 @@ def add_signature_headers(mail, sigs, error_msg):
else:
msg = u'Untrusted: {}'.format(sig_from)
- mail.add_header(X_SIGNATURE_VALID_HEADER, 'False' if error_msg else 'True')
+ mail.add_header(X_SIGNATURE_VALID_HEADER,
+ 'False' if (error_msg or not sig_known) else 'True')
mail.add_header(X_SIGNATURE_MESSAGE_HEADER, msg)
@@ -297,16 +298,20 @@ def extract_headers(mail, headers=None):
def extract_body(mail, types=None, field_key='copiousoutput'):
- """
- returns a body text string for given mail.
- If types is `None`, `text/*` is used:
- The exact preferred type is specified by the prefer_plaintext config option
- which defaults to text/html.
+ """Returns a string view of a Message.
+
+ If the `types` argument is set then any encoding types there will be used
+ as the prefered encoding to extract. If `types` is None then
+ :ref:`prefer_plaintext <prefer-plaintext>` will be consulted; if it is True
+ then text/plain parts will be returned, if it is false then text/html will
+ be returned if present or text/plain if there are no text/html parts.
:param mail: the mail to use
:type mail: :class:`email.Message`
:param types: mime content types to use for body string
- :type types: list of str
+ :type types: list[str]
+ :returns: The combined text of any parts to be used
+ :rtype: str
"""
preferred = 'text/plain' if settings.get(
diff --git a/alot/defaults/alot.rc.spec b/alot/defaults/alot.rc.spec
index 97de329d..eeae4348 100644
--- a/alot/defaults/alot.rc.spec
+++ b/alot/defaults/alot.rc.spec
@@ -355,6 +355,14 @@ thread_focus_linewise = boolean(default=True)
# use your default key.
gpg_key = gpg_key_hint(default=None)
+ # Whether the server treats the address as case-senstive or
+ # case-insensitve (True for the former, False for the latter)
+ #
+ # .. note:: The vast majority (if not all) SMTP servers in modern use
+ # treat usernames as case insenstive, you should only set
+ # this if you know that you need it.
+ case_sensitive_username = boolean(default=False)
+
# address book for this account
[[[abook]]]
# type identifier for address book
diff --git a/alot/settings/manager.py b/alot/settings/manager.py
index cf26f941..c71fba42 100644
--- a/alot/settings/manager.py
+++ b/alot/settings/manager.py
@@ -25,7 +25,8 @@ from .theme import Theme
DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..', 'defaults')
-DATA_DIRS = os.environ.get('XDG_DATA_DIRS', '/usr/local/share:/usr/share').split(':')
+DATA_DIRS = os.environ.get('XDG_DATA_DIRS',
+ '/usr/local/share:/usr/share').split(':')
class SettingsManager(object):
@@ -37,8 +38,10 @@ class SettingsManager(object):
:param notmuch_rc: path to notmuch's config file
:type notmuch_rc: str
"""
- assert alot_rc is None or (isinstance(alot_rc, basestring) and os.path.exists(alot_rc))
- assert notmuch_rc is None or (isinstance(notmuch_rc, basestring) and os.path.exists(notmuch_rc))
+ assert alot_rc is None or (isinstance(alot_rc, basestring) and
+ os.path.exists(alot_rc))
+ assert notmuch_rc is None or (isinstance(notmuch_rc, basestring) and
+ os.path.exists(notmuch_rc))
self.hooks = None
self._mailcaps = mailcap.getcaps()
self._notmuchconfig = None
@@ -62,7 +65,8 @@ class SettingsManager(object):
Implementation Detail: this is the same code called by the constructor
to set bindings at alot startup.
"""
- self._bindings = ConfigObj(os.path.join(DEFAULTSPATH, 'default.bindings'))
+ self._bindings = ConfigObj(os.path.join(DEFAULTSPATH,
+ 'default.bindings'))
self.read_notmuch_config()
self.read_config()
diff --git a/alot/settings/utils.py b/alot/settings/utils.py
index ea56b264..d87157c3 100644
--- a/alot/settings/utils.py
+++ b/alot/settings/utils.py
@@ -9,6 +9,7 @@ from urwid import AttrSpec
from .errors import ConfigError
+
def read_config(configpath=None, specpath=None, checks=None):
"""
get a (validated) config object for given config file path.
diff --git a/alot/utils/cached_property.py b/alot/utils/cached_property.py
index e6187283..680cd6f4 100644
--- a/alot/utils/cached_property.py
+++ b/alot/utils/cached_property.py
@@ -1,34 +1,34 @@
# verbatim from werkzeug.utils.cached_property
#
-#Copyright (c) 2014 by the Werkzeug Team, see AUTHORS for more details.
+# Copyright (c) 2014 by the Werkzeug Team, see AUTHORS for more details.
#
-#Redistribution and use in source and binary forms, with or without
-#modification, are permitted provided that the following conditions are
-#met:
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
#
-# * Redistributions of source code must retain the above copyright
-# notice, this list of conditions and the following disclaimer.
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
#
-# * Redistributions in binary form must reproduce the above
-# copyright notice, this list of conditions and the following
-# disclaimer in the documentation and/or other materials provided
-# with the distribution.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following
+# disclaimer in the documentation and/or other materials provided
+# with the distribution.
#
-# * The names of the contributors may not be used to endorse or
-# promote products derived from this software without specific
-# prior written permission.
+# * The names of the contributors may not be used to endorse or
+# promote products derived from this software without specific
+# prior written permission.
#
-#THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-#"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-#LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-#A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-#OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-#SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-#LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-#DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-#THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-#(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-#OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
_missing = object()
diff --git a/alot/widgets/globals.py b/alot/widgets/globals.py
index 50ef80c6..3aea7622 100644
--- a/alot/widgets/globals.py
+++ b/alot/widgets/globals.py
@@ -86,8 +86,8 @@ class CompleteEdit(urwid.Edit):
The interpretation of some keypresses is hard-wired:
:enter: calls 'on_exit' callback with current value
- :esc/ctrl g: calls 'on_exit' with value `None`, which can be interpreted
- as cancellation
+ :esc/ctrl g: calls 'on_exit' with value `None`, which can be
+ interpreted as cancellation
:tab: calls the completer and tabs forward in the result list
:shift tab: tabs backward in the result list
:up/down: move in the local input history
diff --git a/alot/widgets/search.py b/alot/widgets/search.py
index 905887cc..446a8964 100644
--- a/alot/widgets/search.py
+++ b/alot/widgets/search.py
@@ -115,8 +115,9 @@ class ThreadlineWidget(urwid.AttrMap):
if self.thread:
fallback_normal = struct[name]['normal']
fallback_focus = struct[name]['focus']
- tag_widgets = sorted(TagWidget(t, fallback_normal, fallback_focus)
- for t in self.thread.get_tags())
+ tag_widgets = sorted(
+ TagWidget(t, fallback_normal, fallback_focus)
+ for t in self.thread.get_tags())
else:
tag_widgets = []
cols = []
diff --git a/docs/source/api/settings.rst b/docs/source/api/settings.rst
index a0491f11..05166f3f 100644
--- a/docs/source/api/settings.rst
+++ b/docs/source/api/settings.rst
@@ -54,6 +54,8 @@ Accounts
.. module:: alot.account
+.. autoclass:: Address
+ :members:
.. autoclass:: Account
:members:
.. autoclass:: SendmailAccount
diff --git a/docs/source/configuration/accounts_table b/docs/source/configuration/accounts_table
index b9de9042..27e7529a 100644
--- a/docs/source/configuration/accounts_table
+++ b/docs/source/configuration/accounts_table
@@ -169,3 +169,18 @@
:type: string
:default: None
+
+.. _case-sensitive-username:
+
+.. describe:: case_sensitive_username
+
+ Whether the server treats the address as case-senstive or
+ case-insensitve (True for the former, False for the latter)
+
+ .. note:: The vast majority (if not all) SMTP servers in modern use
+ treat usernames as case insenstive, you should only set
+ this if you know that you need it.
+
+ :type: boolean
+ :default: False
+
diff --git a/tests/account_test.py b/tests/account_test.py
index 9b4bf9c0..33ec076b 100644
--- a/tests/account_test.py
+++ b/tests/account_test.py
@@ -52,3 +52,114 @@ class TestAccount(unittest.TestCase):
acct = _AccountTestClass(address=u'foo@example.com',
encrypt_by_default=each)
self.assertEqual(acct.encrypt_by_default, u'none')
+
+
+class TestAddress(unittest.TestCase):
+
+ """Tests for the Address class."""
+
+ def test_constructor_bytes(self):
+ with self.assertRaises(AssertionError):
+ account.Address(b'username', b'domainname')
+
+ def test_from_string_bytes(self):
+ with self.assertRaises(AssertionError):
+ account.Address.from_string(b'user@example.com')
+
+ def test_from_string(self):
+ addr = account.Address.from_string(u'user@example.com')
+ self.assertEqual(addr.username, u'user')
+ self.assertEqual(addr.domainname, u'example.com')
+
+ def test_unicode(self):
+ addr = account.Address(u'ušer', u'example.com')
+ self.assertEqual(unicode(addr), u'ušer@example.com')
+
+ def test_str(self):
+ addr = account.Address(u'ušer', u'example.com')
+ self.assertEqual(str(addr), u'ušer@example.com'.encode('utf-8'))
+
+ def test_eq_unicode(self):
+ addr = account.Address(u'ušer', u'example.com')
+ self.assertEqual(addr, u'ušer@example.com')
+
+ def test_eq_address(self):
+ addr = account.Address(u'ušer', u'example.com')
+ addr2 = account.Address(u'ušer', u'example.com')
+ self.assertEqual(addr, addr2)
+
+ def test_ne_unicode(self):
+ addr = account.Address(u'ušer', u'example.com')
+ self.assertNotEqual(addr, u'user@example.com')
+
+ def test_ne_address(self):
+ addr = account.Address(u'ušer', u'example.com')
+ addr2 = account.Address(u'user', u'example.com')
+ self.assertNotEqual(addr, addr2)
+
+ def test_eq_unicode_case(self):
+ addr = account.Address(u'UŠer', u'example.com')
+ self.assertEqual(addr, u'ušer@example.com')
+
+ def test_ne_unicode_case(self):
+ addr = account.Address(u'ušer', u'example.com')
+ self.assertEqual(addr, u'uŠer@example.com')
+
+ def test_ne_address_case(self):
+ addr = account.Address(u'ušer', u'example.com')
+ addr2 = account.Address(u'uŠer', u'example.com')
+ self.assertEqual(addr, addr2)
+
+ def test_eq_address_case(self):
+ addr = account.Address(u'UŠer', u'example.com')
+ addr2 = account.Address(u'ušer', u'example.com')
+ self.assertEqual(addr, addr2)
+
+ def test_eq_unicode_case_sensitive(self):
+ addr = account.Address(u'UŠer', u'example.com', case_sensitive=True)
+ self.assertNotEqual(addr, u'ušer@example.com')
+
+ def test_eq_address_case_sensitive(self):
+ addr = account.Address(u'UŠer', u'example.com', case_sensitive=True)
+ addr2 = account.Address(u'ušer', u'example.com')
+ self.assertNotEqual(addr, addr2)
+
+ def test_eq_str(self):
+ addr = account.Address(u'user', u'example.com', case_sensitive=True)
+ with self.assertRaises(TypeError):
+ addr == 1 # pylint: disable=pointless-statement
+
+ def test_ne_str(self):
+ addr = account.Address(u'user', u'example.com', case_sensitive=True)
+ with self.assertRaises(TypeError):
+ addr != 1 # pylint: disable=pointless-statement
+
+ def test_repr(self):
+ addr = account.Address(u'user', u'example.com', case_sensitive=True)
+ self.assertEqual(
+ repr(addr),
+ "Address(u'user', u'example.com', case_sensitive=True)")
+
+ def test_domain_name_ne(self):
+ addr = account.Address(u'user', u'example.com')
+ self.assertNotEqual(addr, u'user@example.org')
+
+ def test_domain_name_eq_case(self):
+ addr = account.Address(u'user', u'example.com')
+ self.assertEqual(addr, u'user@Example.com')
+
+ def test_domain_name_ne_unicode(self):
+ addr = account.Address(u'user', u'éxample.com')
+ self.assertNotEqual(addr, u'user@example.com')
+
+ def test_domain_name_eq_unicode(self):
+ addr = account.Address(u'user', u'éxample.com')
+ self.assertEqual(addr, u'user@Éxample.com')
+
+ def test_domain_name_eq_case_sensitive(self):
+ addr = account.Address(u'user', u'example.com', case_sensitive=True)
+ self.assertEqual(addr, u'user@Example.com')
+
+ def test_domain_name_eq_unicode_sensitive(self):
+ addr = account.Address(u'user', u'éxample.com', case_sensitive=True)
+ self.assertEqual(addr, u'user@Éxample.com')
diff --git a/tests/commands/thread_test.py b/tests/commands/thread_test.py
index ede9e3b7..6897a953 100644
--- a/tests/commands/thread_test.py
+++ b/tests/commands/thread_test.py
@@ -151,77 +151,77 @@ class TestDetermineSender(unittest.TestCase):
self.assertTupleEqual(cm2.exception.args, expected)
def test_default_account_is_used_if_no_match_is_found(self):
- account1 = _AccountTestClass(address='foo@example.com')
- account2 = _AccountTestClass(address='bar@example.com')
- expected = ('foo@example.com', account1)
+ account1 = _AccountTestClass(address=u'foo@example.com')
+ account2 = _AccountTestClass(address=u'bar@example.com')
+ expected = (u'foo@example.com', account1)
self._test(accounts=[account1, account2], expected=expected)
def test_matching_address_and_account_are_returned(self):
- account1 = _AccountTestClass(address='foo@example.com')
- account2 = _AccountTestClass(address='to@example.com')
- account3 = _AccountTestClass(address='bar@example.com')
- expected = ('to@example.com', account2)
+ account1 = _AccountTestClass(address=u'foo@example.com')
+ account2 = _AccountTestClass(address=u'to@example.com')
+ account3 = _AccountTestClass(address=u'bar@example.com')
+ expected = (u'to@example.com', account2)
self._test(accounts=[account1, account2, account3], expected=expected)
def test_force_realname_includes_real_name_in_returned_address_if_defined(self):
- account1 = _AccountTestClass(address='foo@example.com')
- account2 = _AccountTestClass(address='to@example.com', realname='Bar')
- account3 = _AccountTestClass(address='baz@example.com')
- expected = ('Bar <to@example.com>', account2)
+ account1 = _AccountTestClass(address=u'foo@example.com')
+ account2 = _AccountTestClass(address=u'to@example.com', realname='Bar')
+ account3 = _AccountTestClass(address=u'baz@example.com')
+ expected = (u'Bar <to@example.com>', account2)
self._test(accounts=[account1, account2, account3], expected=expected,
force_realname=True)
def test_doesnt_fail_with_force_realname_if_real_name_not_defined(self):
- account1 = _AccountTestClass(address='foo@example.com')
- account2 = _AccountTestClass(address='to@example.com')
- account3 = _AccountTestClass(address='bar@example.com')
- expected = ('to@example.com', account2)
+ account1 = _AccountTestClass(address=u'foo@example.com')
+ account2 = _AccountTestClass(address=u'to@example.com')
+ account3 = _AccountTestClass(address=u'bar@example.com')
+ expected = (u'to@example.com', account2)
self._test(accounts=[account1, account2, account3], expected=expected,
force_realname=True)
def test_with_force_address_main_address_is_used_regardless_of_matching_address(self):
# In python 3.4 this and the next test could be written as subtests.
- account1 = _AccountTestClass(address='foo@example.com')
- account2 = _AccountTestClass(address='bar@example.com',
- aliases=['to@example.com'])
- account3 = _AccountTestClass(address='bar@example.com')
- expected = ('bar@example.com', account2)
+ account1 = _AccountTestClass(address=u'foo@example.com')
+ account2 = _AccountTestClass(address=u'bar@example.com',
+ aliases=[u'to@example.com'])
+ account3 = _AccountTestClass(address=u'bar@example.com')
+ expected = (u'bar@example.com', account2)
self._test(accounts=[account1, account2, account3], expected=expected,
force_address=True)
def test_without_force_address_matching_address_is_used(self):
# In python 3.4 this and the previous test could be written as
# subtests.
- account1 = _AccountTestClass(address='foo@example.com')
- account2 = _AccountTestClass(address='bar@example.com',
- aliases=['to@example.com'])
- account3 = _AccountTestClass(address='baz@example.com')
- expected = ('to@example.com', account2)
+ account1 = _AccountTestClass(address=u'foo@example.com')
+ account2 = _AccountTestClass(address=u'bar@example.com',
+ aliases=[u'to@example.com'])
+ account3 = _AccountTestClass(address=u'baz@example.com')
+ expected = (u'to@example.com', account2)
self._test(accounts=[account1, account2, account3], expected=expected,
force_address=False)
def test_uses_to_header_if_present(self):
- account1 = _AccountTestClass(address='foo@example.com')
- account2 = _AccountTestClass(address='to@example.com')
- account3 = _AccountTestClass(address='bar@example.com')
- expected = ('to@example.com', account2)
+ account1 = _AccountTestClass(address=u'foo@example.com')
+ account2 = _AccountTestClass(address=u'to@example.com')
+ account3 = _AccountTestClass(address=u'bar@example.com')
+ expected = (u'to@example.com', account2)
self._test(accounts=[account1, account2, account3], expected=expected)
def test_header_order_is_more_important_than_accounts_order(self):
- account1 = _AccountTestClass(address='cc@example.com')
- account2 = _AccountTestClass(address='to@example.com')
- account3 = _AccountTestClass(address='bcc@example.com')
- expected = ('to@example.com', account2)
+ account1 = _AccountTestClass(address=u'cc@example.com')
+ account2 = _AccountTestClass(address=u'to@example.com')
+ account3 = _AccountTestClass(address=u'bcc@example.com')
+ expected = (u'to@example.com', account2)
self._test(accounts=[account1, account2, account3], expected=expected)
def test_accounts_can_be_found_by_alias_regex_setting(self):
- account1 = _AccountTestClass(address='foo@example.com')
- account2 = _AccountTestClass(address='to@example.com',
+ account1 = _AccountTestClass(address=u'foo@example.com')
+ account2 = _AccountTestClass(address=u'to@example.com',
alias_regexp=r'to\+.*@example.com')
- account3 = _AccountTestClass(address='bar@example.com')
- mailstring = self.mailstring.replace('to@example.com',
- 'to+some_tag@example.com')
+ account3 = _AccountTestClass(address=u'bar@example.com')
+ mailstring = self.mailstring.replace(u'to@example.com',
+ u'to+some_tag@example.com')
mail = email.message_from_string(mailstring)
- expected = ('to+some_tag@example.com', account2)
+ expected = (u'to+some_tag@example.com', account2)
self._test(accounts=[account1, account2, account3], expected=expected,
mail=mail)
diff --git a/tests/db/utils_test.py b/tests/db/utils_test.py
index 26768597..3e7ef9d3 100644
--- a/tests/db/utils_test.py
+++ b/tests/db/utils_test.py
@@ -1,5 +1,6 @@
# encoding: utf-8
# Copyright (C) 2017 Lucas Hoffmann
+# Copyright © 2017 Dylan Baker
# This file is released under the GNU GPL, version 3 or a later revision.
# For further details see the COPYING file
from __future__ import absolute_import
@@ -21,6 +22,7 @@ import mock
from alot import crypto
from alot import helper
from alot.db import utils
+from alot.errors import GPGProblem
from ..utilities import make_key, make_uid, TestCaseClassCleanup
@@ -342,14 +344,14 @@ class TestAddSignatureHeaders(unittest.TestCase):
def add_header(self, header, value):
self.headers.append((header, value))
- def check(self, key, valid):
+ def check(self, key, valid, error_msg=u''):
mail = self.FakeMail()
with mock.patch('alot.db.utils.crypto.get_key',
mock.Mock(return_value=key)), \
mock.patch('alot.db.utils.crypto.check_uid_validity',
mock.Mock(return_value=valid)):
- utils.add_signature_headers(mail, [mock.Mock(fpr='')], u'')
+ utils.add_signature_headers(mail, [mock.Mock(fpr='')], error_msg)
return mail
@@ -388,6 +390,30 @@ class TestAddSignatureHeaders(unittest.TestCase):
self.assertIn(
(utils.X_SIGNATURE_MESSAGE_HEADER, u'Valid: Andreá'), mail.headers)
+ def test_error_message_unicode(self):
+ mail = self.check(mock.Mock(), mock.Mock(), u'error message')
+ self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'False'), mail.headers)
+ self.assertIn(
+ (utils.X_SIGNATURE_MESSAGE_HEADER, u'Invalid: error message'),
+ mail.headers)
+
+ def test_error_message_bytes(self):
+ mail = self.check(mock.Mock(), mock.Mock(), b'error message')
+ self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'False'), mail.headers)
+ self.assertIn(
+ (utils.X_SIGNATURE_MESSAGE_HEADER, u'Invalid: error message'),
+ mail.headers)
+
+ def test_get_key_fails(self):
+ mail = self.FakeMail()
+ with mock.patch('alot.db.utils.crypto.get_key',
+ mock.Mock(side_effect=GPGProblem(u'', 0))):
+ utils.add_signature_headers(mail, [mock.Mock(fpr='')], u'')
+ self.assertIn((utils.X_SIGNATURE_VALID_HEADER, u'False'), mail.headers)
+ self.assertIn(
+ (utils.X_SIGNATURE_MESSAGE_HEADER, u'Untrusted: '),
+ mail.headers)
+
class TestMessageFromFile(TestCaseClassCleanup):
@@ -625,3 +651,141 @@ class TestMessageFromFile(TestCaseClassCleanup):
self.assertIn('This is some text', [n.get_payload() for n in m.walk()])
self.assertIn(utils.X_SIGNATURE_VALID_HEADER, m)
self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m)
+
+
+class TestExtractBody(unittest.TestCase):
+
+ @staticmethod
+ def _set_basic_headers(mail):
+ mail['Subject'] = 'Test email'
+ mail['To'] = 'foo@example.com'
+ mail['From'] = 'bar@example.com'
+
+ def test_single_text_plain(self):
+ mail = email.mime.text.MIMEText('This is an email')
+ self._set_basic_headers(mail)
+ actual = utils.extract_body(mail)
+
+ expected = 'This is an email'
+
+ self.assertEqual(actual, expected)
+
+ def test_two_text_plain(self):
+ mail = email.mime.multipart.MIMEMultipart()
+ self._set_basic_headers(mail)
+ mail.attach(email.mime.text.MIMEText('This is an email'))
+ mail.attach(email.mime.text.MIMEText('This is a second part'))
+
+ actual = utils.extract_body(mail)
+ expected = 'This is an email\n\nThis is a second part'
+
+ self.assertEqual(actual, expected)
+
+ def test_text_plain_and_other(self):
+ mail = email.mime.multipart.MIMEMultipart()
+ self._set_basic_headers(mail)
+ mail.attach(email.mime.text.MIMEText('This is an email'))
+ mail.attach(email.mime.application.MIMEApplication(b'1'))
+
+ actual = utils.extract_body(mail)
+ expected = 'This is an email'
+
+ self.assertEqual(actual, expected)
+
+ def test_text_plain_with_attachment_text(self):
+ mail = email.mime.multipart.MIMEMultipart()
+ self._set_basic_headers(mail)
+ mail.attach(email.mime.text.MIMEText('This is an email'))
+ attachment = email.mime.text.MIMEText('this shouldnt be displayed')
+ attachment['Content-Disposition'] = 'attachment'
+ mail.attach(attachment)
+
+ actual = utils.extract_body(mail)
+ expected = 'This is an email'
+
+ self.assertEqual(actual, expected)
+
+ def _make_mixed_plain_html(self):
+ mail = email.mime.multipart.MIMEMultipart()
+ self._set_basic_headers(mail)
+ mail.attach(email.mime.text.MIMEText('This is an email'))
+ mail.attach(email.mime.text.MIMEText(
+ '<!DOCTYPE html><html><body>This is an html email</body></html>',
+ 'html'))
+ return mail
+
+ @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=True))
+ def test_prefer_plaintext(self):
+ expected = 'This is an email'
+ mail = self._make_mixed_plain_html()
+ actual = utils.extract_body(mail)
+
+ self.assertEqual(actual, expected)
+
+ # Mock the handler to cat, so that no transformations of the html are made
+ # making the result non-deterministic
+ @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=False))
+ @mock.patch('alot.db.utils.settings.mailcap_find_match',
+ mock.Mock(return_value=(None, {'view': 'cat'})))
+ def test_prefer_html(self):
+ expected = '<!DOCTYPE html><html><body>This is an html email</body></html>'
+ mail = self._make_mixed_plain_html()
+ actual = utils.extract_body(mail)
+
+ self.assertEqual(actual, expected)
+
+ @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=False))
+ @mock.patch('alot.db.utils.settings.mailcap_find_match',
+ mock.Mock(return_value=(None, {'view': 'cat'})))
+ def test_types_provided(self):
+ # This should not return html, even though html is set to preferred
+ # since a types variable is passed
+ expected = 'This is an email'
+ mail = self._make_mixed_plain_html()
+ actual = utils.extract_body(mail, types=['text/plain'])
+
+ self.assertEqual(actual, expected)
+
+ @mock.patch('alot.db.utils.settings.mailcap_find_match',
+ mock.Mock(return_value=(None, {'view': 'cat'})))
+ def test_require_mailcap_stdin(self):
+ mail = email.mime.multipart.MIMEMultipart()
+ self._set_basic_headers(mail)
+ mail.attach(email.mime.text.MIMEText(
+ '<!DOCTYPE html><html><body>This is an html email</body></html>',
+ 'html'))
+ actual = utils.extract_body(mail)
+ expected = '<!DOCTYPE html><html><body>This is an html email</body></html>'
+
+ self.assertEqual(actual, expected)
+
+ @mock.patch('alot.db.utils.settings.mailcap_find_match',
+ mock.Mock(return_value=(None, {'view': 'cat %s'})))
+ def test_require_mailcap_file(self):
+ mail = email.mime.multipart.MIMEMultipart()
+ self._set_basic_headers(mail)
+ mail.attach(email.mime.text.MIMEText(
+ '<!DOCTYPE html><html><body>This is an html email</body></html>',
+ 'html'))
+ actual = utils.extract_body(mail)
+ expected = '<!DOCTYPE html><html><body>This is an html email</body></html>'
+
+ self.assertEqual(actual, expected)
+
+
+class TestMessageFromString(unittest.TestCase):
+
+ """Tests for message_from_string.
+
+ Because the implementation is that this is a wrapper around
+ message_from_file, it's not important to have a large swath of tests, just
+ enough to show that things are being passed correctly.
+ """
+
+ def test(self):
+ m = email.mime.text.MIMEText(u'This is some text', 'plain', 'utf-8')
+ m['Subject'] = 'test'
+ m['From'] = 'me'
+ m['To'] = 'Nobody'
+ message = utils.message_from_string(m.as_string())
+ self.assertEqual(message.get_payload(), 'This is some text')
diff --git a/tests/settings/manager_test.py b/tests/settings/manager_test.py
index 0ce29259..a09dbf15 100644
--- a/tests/settings/manager_test.py
+++ b/tests/settings/manager_test.py
@@ -123,17 +123,17 @@ class TestSettingsManagerGetAccountByAddress(utilities.TestCaseClassCleanup):
cls.manager = SettingsManager(alot_rc=f.name)
def test_exists_addr(self):
- acc = self.manager.get_account_by_address('that_guy@example.com')
+ acc = self.manager.get_account_by_address(u'that_guy@example.com')
self.assertEqual(acc.realname, 'That Guy')
def test_doesnt_exist_return_default(self):
- acc = self.manager.get_account_by_address('doesntexist@example.com',
+ acc = self.manager.get_account_by_address(u'doesntexist@example.com',
return_default=True)
self.assertEqual(acc.realname, 'That Guy')
def test_doesnt_exist_raise(self):
with self.assertRaises(NoMatchingAccount):
- self.manager.get_account_by_address('doesntexist@example.com')
+ self.manager.get_account_by_address(u'doesntexist@example.com')
def test_doesnt_exist_no_default(self):
with tempfile.NamedTemporaryFile() as f:
@@ -147,3 +147,14 @@ class TestSettingsManagerGetAccountByAddress(utilities.TestCaseClassCleanup):
acc = self.manager.get_account_by_address(
'That Guy <a_dude@example.com>')
self.assertEqual(acc.realname, 'A Dude')
+
+ def test_address_case(self):
+ """Some servers do not differentiate addresses by case.
+
+ So, for example, "foo@example.com" and "Foo@example.com" would be
+ considered the same. Among servers that do this gmail, yahoo, fastmail,
+ anything running Exchange (i.e., most large corporations), and others.
+ """
+ acc1 = self.manager.get_account_by_address('That_guy@example.com')
+ acc2 = self.manager.get_account_by_address('that_guy@example.com')
+ self.assertIs(acc1, acc2)