diff options
author | Patrick Totzke <patricktotzke@gmail.com> | 2017-09-02 08:35:01 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-09-02 08:35:01 +0100 |
commit | 668925817cd8aeed7ef9926969d49c4586c26adf (patch) | |
tree | 0cd16d2279a971d0b5d650f03ee3f6ce0e592852 | |
parent | 3a3898f2ce976fbb800f55764675c4962e7adf28 (diff) | |
parent | c09196eb92af61ce0efa2f2ea47f42856ef87ac9 (diff) |
Merge branch 'master' into fix/spelling
-rw-r--r-- | .codeclimate.yml | 6 | ||||
-rw-r--r-- | alot/account.py | 149 | ||||
-rw-r--r-- | alot/addressbook/abook.py | 3 | ||||
-rw-r--r-- | alot/commands/__init__.py | 1 | ||||
-rw-r--r-- | alot/commands/envelope.py | 9 | ||||
-rw-r--r-- | alot/commands/globals.py | 9 | ||||
-rw-r--r-- | alot/commands/thread.py | 6 | ||||
-rw-r--r-- | alot/crypto.py | 44 | ||||
-rw-r--r-- | alot/db/manager.py | 3 | ||||
-rw-r--r-- | alot/db/message.py | 9 | ||||
-rw-r--r-- | alot/db/utils.py | 29 | ||||
-rw-r--r-- | alot/defaults/alot.rc.spec | 8 | ||||
-rw-r--r-- | alot/settings/manager.py | 12 | ||||
-rw-r--r-- | alot/settings/utils.py | 1 | ||||
-rw-r--r-- | alot/utils/cached_property.py | 48 | ||||
-rw-r--r-- | alot/widgets/globals.py | 4 | ||||
-rw-r--r-- | alot/widgets/search.py | 5 | ||||
-rw-r--r-- | docs/source/api/settings.rst | 2 | ||||
-rw-r--r-- | docs/source/configuration/accounts_table | 15 | ||||
-rw-r--r-- | tests/account_test.py | 111 | ||||
-rw-r--r-- | tests/commands/thread_test.py | 78 | ||||
-rw-r--r-- | tests/db/utils_test.py | 168 | ||||
-rw-r--r-- | tests/settings/manager_test.py | 17 |
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) |