pax_global_header00006660000000000000000000000064145325643310014520gustar00rootroot0000000000000052 comment=9a6a0df2d8d44c1f3d57c222d234740f8d381395 imapclient-3.0.1/000077500000000000000000000000001453256433100136465ustar00rootroot00000000000000imapclient-3.0.1/.github/000077500000000000000000000000001453256433100152065ustar00rootroot00000000000000imapclient-3.0.1/.github/dependabot.yml000066400000000000000000000001531453256433100200350ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" imapclient-3.0.1/.github/release.yml000066400000000000000000000003151453256433100173500ustar00rootroot00000000000000changelog: categories: - title: Breaking Changes labels: - breaking - title: New Features labels: - enhancement - title: Other Changes labels: - "*" imapclient-3.0.1/.github/workflows/000077500000000000000000000000001453256433100172435ustar00rootroot00000000000000imapclient-3.0.1/.github/workflows/lint.yml000066400000000000000000000020761453256433100207410ustar00rootroot00000000000000name: Lint # If a pull-request is pushed then cancel all previously running jobs related # to that pull-request concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true on: push: branches: - master pull_request: branches: - master env: PY_COLORS: 1 jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - uses: actions/setup-python@v3 - run: pip install --upgrade tox - name: Run black code formatter (https://black.readthedocs.io/en/stable/) run: tox -e black -- --check - name: Run isort import order checker (https://pycqa.github.io/isort/) run: tox -e isort -- --check - name: Run flake8 (https://flake8.pycqa.org/en/latest/) run: tox -e flake8 - name: Run pylint Python code static checker (https://github.com/PyCQA/pylint) run: tox -e pylint - name: Run mypy static typing checker (https://www.mypy-lang.org/) run: tox -e mypy imapclient-3.0.1/.github/workflows/main.yml000066400000000000000000000013401453256433100207100ustar00rootroot00000000000000name: build on: push: branches: - master pull_request: branches: - master workflow_dispatch: jobs: test: runs-on: ubuntu-20.04 timeout-minutes: 10 strategy: fail-fast: false matrix: python-version: - "3.7" - "3.8" - "3.9" - "3.10" - "3.11" - "pypy-3.9" steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Print Python version under test run: python --version - name: Install test dependencies run: python -m pip install --editable .[test] - name: Test run: python -m unittest --verbose imapclient-3.0.1/.gitignore000066400000000000000000000003161453256433100156360ustar00rootroot00000000000000*.pyc *.swp *~ build/ dist/ __pycache__/ IMAPClient.egg-info doc/doctrees/ doc/html/ .tox/ .eggs/ setuptools-* unittest2-* six-* # By explicity not ignoring these, ripgrep (rg) will search them. !.github/ imapclient-3.0.1/.readthedocs.yaml000066400000000000000000000006001453256433100170710ustar00rootroot00000000000000# Read the Docs configuration file for Sphinx projects # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-22.04 tools: python: "3.11" # Build documentation in the "docs/" directory with Sphinx sphinx: configuration: doc/src/conf.py fail_on_warning: true python: install: - requirements: requirements-dev.txt imapclient-3.0.1/AUTHORS.rst000066400000000000000000000015421453256433100155270ustar00rootroot00000000000000IMAPClient was created by Menno Finlay-Smits . The project is now maintained by Nicolas Le Manchet and Menno Finlay-Smits. Many thanks go to the following people for their help with this project: - Maxime Lorant - Mathieu Agopian - Chris Arndt - Jp Calderone - John Louis del Rosario - Dave Eckhardt - Eben Freeman - Helder Guerreiro - Mark Hammond - Johannes Heckel - Thomas Jost - Lukasz Mierzwa - Naveen Nathan - Brian Neal - Phil Peterson - Aviv Salem - Andrew Scheller - Thomas Steinacher - Zac Witte - Hans-Peter Jansen - Carson Ip - Jonny Hatch - Jasper Spaans - Fabio Manganiello - Samir M - Devin Bayer - Mantas Mikulėnas - @zrose584 - Michał Górny - François Deppierraz - Jasper Spaans - Boni Lindsley - Tobias Kölling - @pinoatrome - Shoaib Ahmed - John Villalovos - Claude Paroz - Stefan Wójcik - Andrzej Bartosiński - @axoroll7 imapclient-3.0.1/COPYING000066400000000000000000000027141453256433100147050ustar00rootroot00000000000000Copyright (c) 2014, Menno Smits All rights reserved. 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 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. * Neither the name of Menno Smits nor the names of its contributors may 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 MENNO SMITS 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. imapclient-3.0.1/MANIFEST.in000066400000000000000000000003611453256433100154040ustar00rootroot00000000000000include README.rst NEWS.rst HACKING.rst COPYING AUTHORS THANKS include livetest.py include ez_setup.py include interact.py include tests/*.py recursive-include imapclient/examples * recursive-include doc *.txt *.rst *.py *.png *.css *.html imapclient-3.0.1/NEWS.rst000066400000000000000000000005671453256433100151640ustar00rootroot00000000000000IMAPClient's release history is now part of the main documentation_. See the `release documentation`_ on `Read the Docs`_ for the most up-to-date details of IMAPClient's releases. .. _documentation: https://imapclient.readthedocs.io/en/master/ .. _release documentation: https://imapclient.readthedocs.io/en/master/#release-history .. _Read the Docs: http://readthedocs.org imapclient-3.0.1/README.rst000066400000000000000000000145101453256433100153360ustar00rootroot00000000000000Essentials ---------- IMAPClient is an easy-to-use, Pythonic and complete IMAP client library. ========================= ======================================== Current version 3.0.1 Supported Python versions 3.7 - 3.11 License New BSD Project home https://github.com/mjs/imapclient/ PyPI https://pypi.python.org/pypi/IMAPClient Documentation https://imapclient.readthedocs.io/ Discussions https://github.com/mjs/imapclient/discussions Test Status |build master| ========================= ======================================== .. |build master| image:: https://github.com/mjs/imapclient/actions/workflows/main.yml/badge.svg :target: https://github.com/mjs/imapclient/actions :alt: master branch Features -------- - Arguments and return values are natural Python types. - IMAP server responses are fully parsed and readily usable. - IMAP unique message IDs (UIDs) are handled transparently. There is no need to call different methods to use UIDs. - Escaping for internationalised mailbox names is transparently handled. Unicode mailbox names may be passed as input wherever a folder name is accepted. - Time zones are transparently handled including when the server and client are in different zones. - Convenience methods are provided for commonly used functionality. - Exceptions are raised when errors occur. Example ------- .. code-block:: python from imapclient import IMAPClient # context manager ensures the session is cleaned up with IMAPClient(host="imap.host.org") as client: client.login('someone', 'secret') client.select_folder('INBOX') # search criteria are passed in a straightforward way # (nesting is supported) messages = client.search(['NOT', 'DELETED']) # fetch selectors are passed as a simple list of strings. response = client.fetch(messages, ['FLAGS', 'RFC822.SIZE']) # `response` is keyed by message id and contains parsed, # converted response items. for message_id, data in response.items(): print('{id}: {size} bytes, flags={flags}'.format( id=message_id, size=data[b'RFC822.SIZE'], flags=data[b'FLAGS'])) Why IMAPClient? --------------- You may ask: "why create another IMAP client library for Python? Doesn't the Python standard library already have imaplib?". The problem with imaplib is that it's very low-level. It expects string values where lists or tuples would be more appropriate and returns server responses almost unparsed. As IMAP server responses can be quite complex this means everyone using imaplib ends up writing their own fragile parsing routines. Also, imaplib doesn't make good use of exceptions. This means you need to check the return value of each call to imaplib to see if what you just did was successful. IMAPClient actually uses imaplib internally. This may change at some point in the future. Installing IMAPClient --------------------- IMAPClient is listed on PyPI and can be installed with pip:: pip install imapclient More installation methods are described in the documentation. Documentation ------------- IMAPClient's manual is available at http://imapclient.readthedocs.io/. Release notes can be found at http://imapclient.readthedocs.io/#release-history. See the `examples` directory in the root of project source for examples of how to use IMAPClient. Current Status -------------- You should feel confident using IMAPClient for production purposes. In order to clearly communicate version compatibility, IMAPClient will strictly adhere to the `Semantic Versioning `_ scheme from version 1.0 onwards. The project's home page is https://github.com/mjs/imapclient/ (this currently redirects to the IMAPClient Github site). Details about upcoming versions and planned features/fixes can be found in the issue tracker on Github. The maintainers also blog about IMAPClient news. Those articles can be found `here `_. Discussions ----------- `Github Discussions`_ can be used to ask questions, propose changes or praise the project maintainers :) .. _`Github Discussions`: https://github.com/mjs/imapclient/discussions Working on IMAPClient --------------------- The `contributing documentation `_ contains information for those interested in improving IMAPClient. IMAP Servers ------------ IMAPClient is heavily tested against Dovecot, Gmail, Fastmail.fm (who use a modified Cyrus implementation), Office365 and Yahoo. Access to accounts on other IMAP servers/services for testing would be greatly appreciated. Interactive Console ------------------- This script connects an IMAPClient instance using the command line args given and starts an interactive session. This is useful for exploring the IMAPClient API and testing things out, avoiding the steps required to set up an IMAPClient instance. The IPython shell is used if it is installed. Otherwise the code.interact() function from the standard library is used. The interactive console functionality can be accessed running the interact.py script in the root of the source tree or by invoking the interact module like this:: python -m imapclient.interact ... "Live" Tests ------------ IMAPClient includes a series of live, functional tests which exercise it against a live IMAP account. These are useful for ensuring compatibility with a given IMAP server implementation. The livetest functionality are run from the root of the project source like this:: python livetest.py [ optional unittest arguments ] The configuration file format is `described in the main documentation `_. **WARNING**: The operations used by livetest are destructive and could cause unintended loss of data. That said, as of version 0.9, livetest limits its activity to a folder it creates and subfolders of that folder. It *should* be safe to use with any IMAP account but please don't run livetest against a truly important IMAP account. Please include the output of livetest.py with an issue if it fails to run successfully against a particular IMAP server. Reports of successful runs are also welcome. Please include the type and version of the IMAP server, if known. imapclient-3.0.1/build-sdist000077500000000000000000000011351453256433100160170ustar00rootroot00000000000000#!/bin/bash # This script builds the IMAPClient source distribution inside a # virtualenv. # # This is needed to work around this issue with setuptools: # https://bitbucket.org/pypa/setuptools/issue/141 # # In summary: if a different version of one of the "setup_requires" # dependencies is globally installed, then setuptools will abort with # a VersionConflict exception. Building in a virtualenv works around # the bug. env=.build-env rm -rf $env virtualenv $env . $env/bin/activate python setup.py sdist --formats=zip --dist-dir . deactivate rm -rf $env # TODO: validation of the source tarball imapclient-3.0.1/doc/000077500000000000000000000000001453256433100144135ustar00rootroot00000000000000imapclient-3.0.1/doc/src/000077500000000000000000000000001453256433100152025ustar00rootroot00000000000000imapclient-3.0.1/doc/src/advanced.rst000066400000000000000000000125431453256433100175060ustar00rootroot00000000000000Advanced Usage -------------- This document covers some more advanced features and tips for handling specific usages. Cleaning Up Connections ~~~~~~~~~~~~~~~~~~~~~~~ To communicate with the server, IMAPClient establishes a TCP connection. It is important for long-lived processes to always close connections at some point to avoid leaking memory and file descriptors. This is usually done with the ``logout`` method:: import imapclient c = imapclient.IMAPClient(host="imap.foo.org") c.login("bar@foo.org", "passwd") c.select_folder("INBOX") c.logout() However if an error is raised when selecting the folder, the connection may be left open. IMAPClient may be used as a context manager that automatically closes connections when they are not needed any more:: import imapclient with imapclient.IMAPClient(host="imap.foo.org") as c: c.login("bar@foo.org", "passwd") c.select_folder("INBOX") Watching a Mailbox Using IDLE ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The IDLE extension allows an IMAP server to notify a client when something changes in a mailbox. It can be used as an alternative to polling to receive new messages. The concept is simple: the client connects to the server, selects a mailbox and enters the IDLE mode. At this point the server sends notifications whenever something happens in the selected mailbox until the client ends the IDLE mode by issuing a ``DONE`` command. This is explained in :rfc:`2177`. .. literalinclude:: ../../examples/idle_example.py Note that IMAPClient does not handle low-level socket errors that can happen when maintaining long-lived TCP connections. Users are advised to renew the IDLE command every 10 minutes to avoid the connection from being abruptly closed. Interactive Sessions ~~~~~~~~~~~~~~~~~~~~ When developing program using IMAPClient is it sometimes useful to have an interactive shell to play with. IMAPClient ships with a module that lets you fire up an interactive shell with an IMAPClient instance connected to an IMAP server. Start a session like this:: python -m imapclient.interact -H -u ... Various options are available to specify the IMAP server details. See the help (--help) for more details. You'll be prompted for a username and password if one isn't provided on the command line. It is also possible to pass connection details as a configuration file like this:: python -m imapclient.interact -f See below for details of the :ref:`configuration file format`. If installed, IPython will be used as the embedded shell. Otherwise the basic built-in Python shell will be used. The connected IMAPClient instance is available as the variable "c". Here's an example session:: $ python -m imapclient.interact -H -u ... Connecting... Connected. IMAPClient instance is "c" In [1]: c.select_folder('inbox') Out[1]: {b'EXISTS': 2, b'FLAGS': (b'\\Answered', b'\\Flagged', b'\\Deleted', b'\\Seen', b'\\Draft'), b'PERMANENTFLAGS': (b'\\Answered', b'\\Flagged', b'\\Deleted', b'\\Seen', b'\\Draft'), b'READ-WRITE': True, b'RECENT': 0, b'UIDNEXT': 1339, b'UIDVALIDITY': 1239278212} In [2]: c.search() Out[2]: [1123, 1233] In [3]: c.logout() Out[3]: b'Logging out' .. _conf-files: Configuration File Format +++++++++++++++++++++++++ Both the IMAPClient interactive shell and the live tests take configuration files which specify how to to connect to an IMAP server. The configuration file format is the same for both. Configuration files use the INI format and must always have a section called ``DEFAULT``. Here's a simple example:: [DEFAULT] host = imap.mailserver.com username = bob password = sekret ssl = True The supported options are: ==================== ======= ========================================================================================= Name Type Description ==================== ======= ========================================================================================= host string IMAP hostname to connect to. username string The username to authenticate as. password string The password to use with ``username``. port int Server port to connect to. Defaults to 143 unless ``ssl`` is True. ssl bool Use SSL/TLS to connect. starttls bool Use STARTTLS to connect. ssl_check_hostname bool If true and SSL is in use, check that certificate matches the hostname (defaults to true) ssl_verify_cert bool If true and SSL is in use, check that the certifcate is valid (defaults to true). ssl_ca_file string If SSL is true, use this to specify certificate authority certs to validate with. timeout int Time out I/O operations after this many seconds. oauth2 bool If true, use OAUTH2 to authenticate (``username`` and ``password`` are ignored). oauth2_client_id string OAUTH2 client id. oauth2_client_secret string OAUTH2 client secret. oauth2_refresh_token string OAUTH2 token for refreshing the secret. ==================== ======= ========================================================================================= Acceptable boolean values are "1", "yes", "true", and "on", for true; and "0", "no", "false", and "off", for false. imapclient-3.0.1/doc/src/api.rst000066400000000000000000000040231453256433100165040ustar00rootroot00000000000000IMAPClient Class ~~~~~~~~~~~~~~~~ The primary class used by the imapclient package is the IMAPClient class. All interaction with a remote IMAP server is performed via an IMAPClient instance. .. autoclass:: imapclient.IMAPClient :members: .. autoclass:: imapclient.SocketTimeout :members: Fetch Response Types ~~~~~~~~~~~~~~~~~~~~ Various types may be used in the data structures returned by :py:meth:`.IMAPClient.fetch` when certain response types are encountered during parsing. .. automodule:: imapclient.response_types :members: Exceptions ~~~~~~~~~~ IMAPClient wraps exceptions raised by imaplib to ease the error handling. All the exceptions related to IMAP errors are defined in the module `imapclient.exceptions`. The following general exceptions may be raised: * IMAPClientError: the base class for IMAPClient's exceptions and the most commonly used error. * IMAPClientAbortError: raised if a serious error has occurred that means the IMAP connection is no longer usable. The connection should be dropped without logout if this occurs. * IMAPClientReadOnlyError: raised if a modifying operation was attempted on a read-only folder. More specific exceptions existed for common known errors: .. automodule:: imapclient.exceptions :members: Exceptions from lower layers are possible, such as networks error or unicode malformed exception. In particular: * socket.error * socket.timeout: raised if a timeout was specified when creating the IMAPClient instance and a network operation takes too long. * ssl.SSLError: the base class for network or SSL protocol errors when ``ssl=True`` or ``starttls()`` is used. * ssl.CertificateError: raised when TLS certification verification fails. This is *not* a subclass of SSLError. Utilities ~~~~~~~~~ .. automodule:: imapclient.testable_imapclient :members: TLS Support ~~~~~~~~~~~ .. automodule:: imapclient.tls :members: Thread Safety ~~~~~~~~~~~~~ Instances of IMAPClient are NOT thread safe. They should not be shared and accessed concurrently from multiple threads. imapclient-3.0.1/doc/src/concepts.rst000066400000000000000000000134431453256433100175570ustar00rootroot00000000000000IMAPClient Concepts ------------------- Message Identifiers ~~~~~~~~~~~~~~~~~~~ In the IMAP protocol, messages are identified using an integer. These message ids are specific to a given folder. There are two types of message identifiers in the IMAP protocol. One type is the message sequence number where the messages in a folder are numbered from 1 to N where N is the number of messages in the folder. These numbers don't persist between sessions and may be reassigned after some operations such as an expunge. A more convenient approach is Unique Identifiers (UIDs). Unique Identifiers are integers assigned to each message by the IMAP server that will persist across sessions. They do not change when folders are expunged. Almost all IMAP servers support UIDs. Each call to the IMAP server can use either message sequence numbers or UIDs in the command arguments and return values. The client specifies to the server which type of identifier should be used. You can set whether IMAPClient should use UIDs or message sequence number via the *use_uid* argument passed when an IMAPClient instance is created and the *use_uid* attribute. The *use_uid* attribute can be used to change the message id type between calls to the server. IMAPClient uses UIDs by default. Any method that accepts message ids takes either a sequence containing message ids (eg. ``[1,2,3]``), or a single message id integer, or a string representing sets and ranges of messages as supported by the IMAP protocol (e.g. ``'50-65'``, ``'2:*'`` or ``'2,4:7,9,12:*'``). Message Flags ~~~~~~~~~~~~~ An IMAP server keeps zero or more flags for each message. These indicate certain properties of the message or can be used by IMAP clients to keep track of data related to a message. The IMAPClient package has constants for a number of commmonly used flags:: DELETED = br'\Deleted' SEEN = br'\Seen' ANSWERED = br'\Answered' FLAGGED = br'\Flagged' DRAFT = br'\Draft' RECENT = br'\Recent' # This flag is read-only Any method that accepts message flags takes either a sequence containing message flags (eg. ``[DELETED, 'foo', 'Bar']``) or a single message flag (eg. ``'Foo'``). Folder Name Encoding ~~~~~~~~~~~~~~~~~~~~ Any method that takes a folder name will accept a standard string or a unicode string. Unicode strings will be transparently encoded using modified UTF-7 as specified by :rfc:`3501#section-5.1.3`. This allows for arbitrary unicode characters (eg. non-English characters) to be used in folder names. The ampersand character ("&") has special meaning in IMAP folder names. IMAPClient automatically escapes and unescapes this character so that the caller doesn't have to. Automatic folder name encoding and decoding can be enabled or disabled with the *folder_encode* attribute. It defaults to True. If *folder_encode* is True, all folder names returned by IMAPClient are always returned as unicode strings. If *folder_encode* is False, folder names are returned as str (Python 2) or bytes (Python 3). Working With Fetched Messages ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The IMAP protocol gives access to a limited amount of information about emails stored on the server. In depth analysis of a message usually requires downloading the full message and parsing its content. The `email `_ package of the Python standard library provides a reliable way to transform a raw email into a convenient object. .. literalinclude:: ../../examples/email_parsing.py TLS/SSL ~~~~~~~ IMAPClient uses sensible TLS parameter defaults for encrypted connections and also allows for a high level of control of TLS parameters if required. It uses the built-in `ssl` package, provided since Python 2.7.9 and 3.4. TLS parameters are controlled by passing a ``ssl.SSLContext`` when creating an IMAPClient instance (or to the `starttls` method when the STARTTLS is used). When ``ssl=True`` is used without passing a SSLContext, a default context is used. The default context avoids the use of known insecure ciphers and SSL protocol versions, with certificate verification and hostname verification turned on. The default context will use system installed certificate authority trust chains, if available. When constructing a custom context it is usually best to start with the default context, created by the ``ssl`` module, and modify it to suit your needs. The following example shows how to to disable certification verification and certificate host name checks if required. .. literalinclude:: ../../examples/tls_no_checks.py The next example shows how to create a context that will use custom CA certificate. This is required to perform verification of a self-signed certificate used by the IMAP server. .. literalinclude:: ../../examples/tls_cacert.py If your operating system comes with an outdated list of CA certificates you can use the `certifi `_ package that provides an up-to-date set of trusted CAs:: import certifi ssl_context = ssl.create_default_context(cafile=certifi.where()) If the server supports it, you can also authenticate using a client certificate:: import ssl ssl_context = ssl.create_default_context() ssl_context.load_cert_chain("/path/to/client_certificate.crt") The above examples show some of the most common TLS parameter customisations but there are many other tweaks are possible. Consult the Python 3 :py:mod:`ssl` package documentation for further options. Logging ~~~~~~~ IMAPClient logs debug lines using the standard Python :py:mod:`logging` module. Its logger prefix is ``imapclient.``. One way to see debug messages from IMAPClient is to set up logging like this:: import logging logging.basicConfig( format='%(asctime)s - %(levelname)s: %(message)s', level=logging.DEBUG ) For advanced usage, please refer to the documentation ``logging`` module. imapclient-3.0.1/doc/src/conf.py000066400000000000000000000151511453256433100165040ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # All configuration values have a default; values that are commented out # serve to show the default. import sys from os import path sys.path.insert(0, path.abspath(path.join(path.dirname(__file__), "..", ".."))) import imapclient # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.intersphinx", ] intersphinx_mapping = {"python": ("http://docs.python.org/3", None)} # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix of source filenames. source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "IMAPClient" copyright = "2014, Menno Smits" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = imapclient.__version__ # The full version, including alpha/beta/rc tags. release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = "sphinxdoc" html_use_modindex = False html_use_index = False # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = "IMAPClientdoc" # -- Options for LaTeX output -------------------------------------------------- # The paper size ('letter' or 'a4'). # latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). # latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ("index", "IMAPClient.tex", "IMAPClient Documentation", "Menno Smits", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Additional stuff for the LaTeX preamble. # latex_preamble = '' # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [("index", "imapclient", "IMAPClient Documentation", ["Menno Smits"], 1)] imapclient-3.0.1/doc/src/contributing.rst000066400000000000000000000063161453256433100204510ustar00rootroot00000000000000============================ Contributing to IMAPClient ============================ The best way to contribute changes to IMAPClient is to fork the official repository on Github, make changes in a branch in your personal fork and then submit a pull request. Discussion `on Github`_ before undertaking development is highly encouraged for potentially major changes. Although not essential, it will make the project maintainers much happier if change submissions include appropriate updates to unit tests, live tests and documentation. Please ask if you're unsure how of how the tests work. Please read on if you plan on submitting changes to IMAPClient. .. _`on Github`: https://github.com/mjs/imapclient/discussions Source Code =========== The official source code repository for IMAPClient can be found on Github at: https://github.com/mjs/imapclient Any major feature work will also be found as branches of this repository. Branches -------- Development for the next major release happens on the ``master`` branch. There is also a branch for each major release series (for example: ``1.x``). When appropriate and when there will be future releases for a series, changes may be selectively merged between ``master`` and a stable release branch. Release Tags ------------ Each released version is available in the IMAPClient repository as a Git tag (e.g. "0.9.1"). Unit Tests ========== Running Unit Tests ------------------ To run the tests, from the root of the package source run:: python -m unittest --verbose Testing Against Multiple Python Versions ---------------------------------------- When submitting a Pull Request to IMAPClient, tests are automatically run against all the supported Python versions. It is possible to run these tests locally using `tox`_. Once installed, the ``tox`` command will use the tox.ini file in the root of the project source and run the unit tests against the Python versions officially supported by IMAPClient (provided these versions of Python are installed!). .. _`tox`: http://testrun.org/tox/ To avoid having to install all Python versions directly on a host, the ``tox-all`` script can be used. It will run the unit tests inside a Docker container which contains all supported Python versions. As long as Docker is installed and your user account can sudo to root the following should work:: ./tox-all The script passes any arguments on to tox. For example to run just the tests just against Python 3.7 do:: ./tox-all -e py37 Writing Unit Tests ------------------ Protocol level unit tests should not act against a real IMAP server but should use canned data instead. The IMAPClientTest base class should typically be used as the base class for any tests - it provides a mock IMAPClient instance at `self.client`. See the tests in `tests/test_imapclient.py` for examples of how to write unit tests using this approach. Documentation ============= The source for the project's documentation can be found under doc/src in the source distribution. In order to build the documentation you'll need install Sphinx. Running ``pip install '.[doc]'`` from the root of the project source will do this. Once Sphinx is installed, the documentation can be rebuilt using:: python setup.py build_sphinx imapclient-3.0.1/doc/src/index.rst000066400000000000000000000074101453256433100170450ustar00rootroot00000000000000============ IMAPClient ============ :Author: `Menno Finlay-Smits `_ :Version: |release| :Date: |today| :Homepage: http://imapclient.freshfoo.com :Download: http://pypi.python.org/pypi/IMAPClient/ :Source code: https://github.com/mjs/imapclient :Documentation: http://imapclient.readthedocs.io/ :License: `New BSD License `_ :Forum/Support: https://github.com/mjs/imapclient/discussions Introduction ------------ IMAPClient is an easy-to-use, Pythonic and complete IMAP client library. Although IMAPClient actually uses the imaplib module from the Python standard library under the hood, it provides a different API. Instead of requiring that the caller performs extra parsing work, return values are full parsed, readily usable and use sensible Python types. Exceptions are raised when problems occur (no error checking of return values is required). IMAPClient is straightforward to use, but it can be useful to have at least a general understanding of the IMAP protocol. :rfc:`3501` explains IMAP in detail. Other RFCs also apply to various extensions to the base protocol. These are referred to in the documentation below where relevant. Python versions 3.4 through 3.9 are officially supported. Getting Started --------------- Install IMAPClient:: $ pip install imapclient See :ref:`Installation ` for more details. The core of the IMAPClient API is the IMAPClient class. Instantiating this class, creates a connection to an IMAP account. Calling methods on the IMAPClient instance interacts with the server. The following example shows a simple interaction with an IMAP server. It displays the message ID, subject and date of the message for all messages in the INBOX folder. :: >>> from imapclient import IMAPClient >>> server = IMAPClient('imap.mailserver.com', use_uid=True) >>> server.login('someuser', 'somepassword') b'[CAPABILITY IMAP4rev1 LITERAL+ SASL-IR [...] LIST-STATUS QUOTA] Logged in' >>> select_info = server.select_folder('INBOX') >>> print('%d messages in INBOX' % select_info[b'EXISTS']) 34 messages in INBOX >>> messages = server.search(['FROM', 'best-friend@domain.com']) >>> print("%d messages from our best friend" % len(messages)) 5 messages from our best friend >>> for msgid, data in server.fetch(messages, ['ENVELOPE']).items(): >>> envelope = data[b'ENVELOPE'] >>> print('ID #%d: "%s" received %s' % (msgid, envelope.subject.decode(), envelope.date)) ID #62: "Our holidays photos" received 2017-07-20 21:47:42 ID #55: "Re: did you book the hotel?" received 2017-06-26 10:38:09 ID #53: "Re: did you book the hotel?" received 2017-06-25 22:02:58 ID #44: "See that fun article about lobsters in Pacific ocean!" received 2017-06-09 09:49:47 ID #46: "Planning for our next vacations" received 2017-05-12 10:29:30 >>> server.logout() b'Logging out' User Guide ---------- This section describes how IMAPClient works and gives some examples to help you start. .. toctree:: :maxdepth: 2 installation concepts advanced API Reference ------------- This section describes public functions and classes of IMAPClient library. .. toctree:: :maxdepth: 2 api Contributor Guide ----------------- .. toctree:: :maxdepth: 2 contributing External Documentation ---------------------- The `Unofficial IMAP Protocol Wiki `_ is very useful when writing IMAP related software and is highly recommended. Authors ------- .. include:: ../../AUTHORS.rst Release History --------------- From release 3.0.0 onwards, release notes are maintained `on Github `_. Release notes for older versions can be found :doc:`in these docs `. imapclient-3.0.1/doc/src/installation.rst000066400000000000000000000012711453256433100204360ustar00rootroot00000000000000.. _installation: Installation ------------ Pip ~~~ IMAPClient can easily be installed with pip:: $ pip install imapclient From Source ~~~~~~~~~~~ IMAPClient is developed on GitHub, you can find the code at `mjs/imapclient `_. You can clone the public repository:: $ git clone https://github.com/mjs/imapclient.git Once you have the sources, simply install IMAPClient with:: $ cd imapclient $ pip install -e . Other versions ~~~~~~~~~~~~~~ The source distributions of all IMAPClient versions are available at http://menno.io/projects/IMAPClient/. Alternatively you can also use the PyPI page at https://pypi.python.org/pypi/IMAPClient/. imapclient-3.0.1/doc/src/releases.rst000066400000000000000000001052341453256433100175440ustar00rootroot00000000000000:orphan: .. note:: From release 3.0.0 onwards, release notes are maintained `on Github `_. =============== Version 2.3.0 =============== **Note**: This will be the last release to support Python 2. Many thanks to Boni Lindsley for many of the changes in this release. Changes below are by them unless otherwise specified. Changed ------- - Use GitHub Actions instead of TravisCI - Improvements to code examples (thanks shoaib30) - Run tests with unittest instead of setup.py Added ----- - New ``socket()`` method which provides access to the underlying network socket. This is useful for allowing the socket to be polled. - Allow flags and internaldate to be specified for MULTIAPPEND (thanks Tobias Kölling) Fixed ----- - Default SSL contexts are now created with correct purpose (thanks pinoatrome) - Fixed undiscoverable tests due to name shadowing - Fixed missing code block directives in documentation - Fixed typo in tox envlist - Fixed formatting in release notes =============== Version 2.2.0 =============== Changed ------- - Performance improvements (thanks Carson Ip!) - 2x faster _maybe_int_to_bytes for Python 2 (#375) - Fix _proc_folder_list quadratic runtime (#374) - Faster utf7 encode (#373). ~40% faster for input with a mix of unicode and ASCII chars. - Cache regex in _process_select_response - poll() when available to surpass 1024 file descriptor limit with select() (#377) (thanks Jonny Hatch) - Use next instead of six.next as imapclient doesn't claim Python 2.5 support. (#396) (thanks Jasper Spaans) - Moved "Logged in/out" traces from INFO to DEBUG level (thanks Fabio Manganiello) - Run tests on Python 3.8 and 3.9 - Support the Deleted special folder used by Outlook (thanks Samir M) - Clean up timeout handling - Run the Black code formatter over the entire project Added ----- - MULTIAPPEND and LITERAL+ support (#399) (thanks Devin Bayer) - Use ptpython for interactive shell if available (#272) - Allow any custom SASL mechanism to be provided. This allows mechanisms such as EXTERNAL, GSSAPI or SCRAM-SHA-256 to be used in the same way as with imaplib. (thanks Mantas Mikulėnas) - Add SASL OAUTHBEARER support - add optional timeout parameter to IMAP4_TLS.open (thanks zrose584) Fixed ----- - fixed special folder searching - Catch the right exception in folder_status (#371) - test_imapclient: Fix LoggerAdapter version check (#383) (thanks Michał Górny) - Fix config file parsing for None attributes (#393) (thanks François Deppierraz) - Fix useless ref cycle in lexer - Protocol parsing: Prevent converting numbers with leading zeroes to int. (#390) (#405) (thanks Jasper Spaans) - Prevent UnicodeDecodeError in IMAPlibLoggerAdapter (#367) - Fix invalid string escape sequences (#397) - Ensure timeout is used on Python 2.7. _create_socket isn't used with the Python 2 version of imaplib so the open method has been overrided to make it consistent across Python version (#380). - Fix IMAP4_TLS for imaplib in Python 3.9+ (thanks Christopher Arndt, marmarek and link2xt) =============== Version 2.1.0 =============== Changed ------- - TravisCI now runs tests against PyPy - Python 3.7 is now officially supported - Cleaned up server capability checks - Use TLS by default for interactive sessions Added ----- - Support the ``QUOTA`` extension - Support for locating special folders (``find_special_folder()``) - Document usage of client TLS certificates - Added documentation & example for parsing retrieved emails using the standard library ``email`` package. Fixed ----- - Handle ``NIL`` values for ``INTERNALDATE`` ============= Version 2.0 ============= Changed ------- - Only use Python's built-in TLS support (no more backports.ssl & pyOpenSSL) - Connections use SSL/TLS by default (`ssl=True`) - Drop ``imapclient.tls.create_default_context`` function. In case you were using it, you can use the method with the same name available in the built-in ``ssl`` module. - Logs are now handled by the Python logging module. The `debug` and `log_file` attributes are gone. - More precise exceptions available in `imapclient.exceptions` are raised when an error happens - `imapclient.exceptions.ProtocolError` is now raised when the reply from a remote server violates the IMAP protocol. - SEARCH exceptions now link to relevant documentation. - GMail labels are now strings instead of bytes in Python 3. - OAUTH v1 support removed. - setup.py has been simplified. - All non-library code moved out of the `imapclient` package. - Many documentation improvements. Added ----- - Connection and read/write operations timeout can now be distinct, using `imapclient.SocketTimeout` namedtuple as `timeout` parameter. - A context manager is introduced to automatically close connections to remote servers. - EXPUNGE by ID support. - ENABLE support. - UNSELECT support. - Atomically move messages to another folder using the MOVE extension (:rfc:`6851`) - New `welcome` property to allow access to IMAP server greeting. Fixed ----- - GMail labels using international characters are now handled properly. - Don't use locale dependent formatting in `datetime_to_INTERNAL_DATE()`. - Quote empty strings to prevent syntax errors while SEARCHing for zero-length strings. - Handle address without mailbox name or host in Address namedtuple. - Avoid asserts in response parsing codes to allow graceful recovery. - Prevent logging of IMAP passwords. Python compatibility -------------------- Support for Python 2.6 and 3.3 is removed in this release. This version supports Python 2.7, 3.4, 3.5 and 3.6. We officially support the latest release of each series. =============== Version 1.1.0 =============== Added ----- - Search now supports nested criteria so that more complex criteria can be expressed. IMAPClient will add parentheses in the right place. - PLAIN authentication support (via `plain_login` method) - `unselect_folder()` method, for servers with the UNSELECT capability (#200) - Add ENABLE support (#136) - UID EXPUNGE support (#287) Changed ------- - the `mock` package is no longer installed by default (just as a test dependency) - handle NIL date values in INTERNALDATE - add `silent` option to all flags methods (improves performance by avoiding unnecessary parsing) - simplify Gmail label functionality - folder_status is more robust - various livetest reliability improvements Fixed ----- - don't quote search criteria when sent as IMAP literals. Fixes #249. - Modified UTF-7 encoding function had quirks in its original algorithm, leading to incorrect encoded output in some cases. The algorithm, described in RFC 3501, has been reimplemented to fix #187 and is better documented. - use fixed month names when formatting INTERNALDATES (don't rely on locale) - handle address without mailbox name or host in Address namedtuple. Fixes #242. - Use cryptography < 2.0 on Python 3.3. Fixes #305. =============== Version 1.0.2 =============== New --- - Documented the livetest/interact INI file format. - Documented handling of RFC2822 group syntax. Changed ------- - Explicitly check that the required pyOpenSSL version is installed - Start testing against Python 3.5 - Update doc links from readthedocs.org to readthedocs.io - Rearranged README so that project essentials are right at the top. Fixed ----- - Allow installation from alternate directories =============== Version 1.0.1 =============== Changed ------- - Minimum backports.ssl dependency is now 0.0.9 (an important performance issue was addressed) - setuptools 18.8.1 now used due to strange zip file error for 17.1 Fixed ----- - Unit test for version strings were updated to now always include the patch version. - Fresh capabilities now retrieved between STARTTLS and authentication (#195). =============== Version 1.0.0 =============== Enhanced TLS support [API] -------------------------- The way that IMAPClient establishes TLS/SSL connections has been completely reworked. By default IMAPClient will attempt certificate verification, certificate hostname checking, and will not use known-insecure TLS settings and protocols. In addition, TLS parameters are now highly configurable. By leveraging pyOpenSSL and backports.ssl, all Python versions supported by IMAPClient enjoy the same TLS functionality and API. These packages mean that IMAPClient now has a number of new dependencies. These should be installed automatically as required but there will no doubt be complications. Compatibility breaks: 1. Due to lack of support in some of the dependent libraries, IMAPClient no longer supports Python 3.2. 2. The passthrough keyword arguments that the IMAPClient constructor took in past versions are no longer accepted. These were in place to provide access to imaplib's SSL arguments which are no longer relevant. Please pass a SSL context object instead. 3. When using the default SSL context that IMAPClient creates (recommended), certificate verification is enabled. This means that IMAPClient connections to servers that used to work before, may fail now (especially if a self-signed certificate is used by the server). Refer to the documentation for details of how to supply alternate CA certificates or disable verification. 4. There are some new exceptions that might be raised in response to network issues or TLS protocol failures. Refer to the Exceptions_ section of the manual for more details. Please refer to the "TLS/SSL" section of the manual for more details on all of the above. Many thanks to Chris Arndt and Marc-Antoine Parent for their input into these TLS improvements. .. _Exceptions: http://imapclient.readthedocs.io/en/latest/#exceptions STARTTLS support [NEW] ---------------------- When the server supports it, IMAPClient can now establish an encrypted connection after initially starting with an unencrypted connection using the STARTTLS command. The starttls method takes an SSL context object for controlling the parameters of the TLS negotiation. Many thanks to Chris Arndt for his extensive initial work on this. More robust criteria handling for search, sort and thread [API] --------------------------------------------------------------- IMAPClient's methods that accept search criteria (search, sort, thread, gmail_search) have been changed to provide take criteria in a more straightforward and robust way. In addition, the way the *charset* argument interacts with search criteria has been improved. These changes make it easier to pass search criteria and have them handled correctly but unfortunately also mean that small changes may be required to existing code that uses IMAPClient. Search criteria ~~~~~~~~~~~~~~~ The preferred way to specify criteria now is as a list of strings, ints and dates (where relevant). The list should be flat with all the criteria parts run together. Where a criteria takes an argument, just provide it as the next element in the list. Some valid examples:: c.search(['DELETED']) c.search(['NOT', 'DELETED']) c.search(['FLAGGED', 'SUBJECT', 'foo', 'BODY', 'hello world']) c.search(['NOT', 'DELETED', 'SMALLER', 1000]) c.search(['SINCE', date(2006, 5, 3)]) IMAPClient will perform all required conversion, quoting and encoding. Callers do not need to and should not attempt to do this themselves. IMAPClient will automatically send criteria parts as IMAP literals when required (i.e. when the encoded part is 8-bit). Some previously accepted ways of passing search criteria will not work as they did in previous versions of IMAPClient. Small changes will be required in these cases. Here are some examples of how to update code written against older versions of IMAPClient:: c.search(['NOT DELETED']) # Before c.search(['NOT', 'DELETED']) # After c.search(['TEXT "foo"']) # Before c.search(['TEXT', 'foo']) # After (IMAPClient will add the quotes) c.search(['DELETED', 'TEXT "foo"']) # Before c.search(['DELETED', 'TEXT', 'foo']) # After c.search(['SMALLER 1000']) # Before c.search(['SMALLER', 1000]) # After It is also possible to pass a single string as the search criteria. IMAPClient will not attempt quoting in this case, allowing the caller to specify search criteria at a lower level. Specifying criteria using a sequence of strings is preferable however. The following examples (equivalent to those further above) are valid:: c.search('DELETED') c.search('NOT DELETED') c.search('FLAGGED SUBJECT "foo" BODY "hello world"') c.search('NOT DELETED SMALLER 1000') c.search('SINCE 03-May-2006') Search charset ~~~~~~~~~~~~~~ The way that the search *charset* argument is handled has also changed. Any unicode criteria arguments will now be encoded by IMAPClient using the supplied charset. The charset must refer to an encoding that is capable of handling the criteria's characters or an error will occur. The charset must obviously also be one that the server supports! (UTF-8 is common) Any criteria given as bytes will not be changed by IMAPClient, but the provided charset will still be passed to the IMAP server. This allows already encoding criteria to be passed through as-is. The encoding referred to by *charset* should match the actual encoding used for the criteria. The following are valid examples:: c.search(['TEXT', u'\u263a'], 'utf-8') # IMAPClient will apply UTF-8 encoding c.search([b'TEXT', b'\xe2\x98\xba'], 'utf-8') # Caller has already applied UTF-8 encoding The documentation and tests for search, gmail_search, sort and thread has updated to account for these changes and have also been generally improved. Socket timeout support [NEW] ---------------------------- IMAPClient now accepts a timeout at creation time. The timeout applies while establishing the connection and for all operations on the socket connected to the IMAP server. Semantic versioning ------------------- In order to better indicate version compatibility to users, IMAPClient will now strictly adhere to the `Semantic Versioning `_ scheme. Performance optimisation for parsing message id lists ----------------------------------------------------- A short circuit is now used when parsing a list of message ids which greatly speeds up parsing time. Other ----- - Perform quoting of Gmail labels. Thanks to Pawel Sz for the fix. - The type of the various flag constants was fixed. Thanks to Thomi Richards for pointing this out. - Now using mock 1.3.0. Thanks to Thomi Richards for the patch. - Fixed handling of very long numeric only folder names. Thanks to Paweł Gorzelany for the patch. - The default charset for gmail_search is now UTF-8. This makes it easier to use any unicode string as a search string and is safe because Gmail supports UTF-8 search criteria. - PEP8 compliance fixed (except for some occasional long lines) - Added a "shutdown" method. - The embedded six package has been removed in favour of using an externally installed instance. - Fixed handling of literals in STATUS responses. - Only use the untagged post-login CAPABILITY response once (if sent by server). - Release history made part of the main documentation. - Clarified how message ids work in the docs. - Livetest infrastructure now works with Yahoo's OAUTH2 - Fixed bytes handling in Address.__str__ ============== Version 0.13 ============== Added support for the ID command [NEW] -------------------------------------- As per RFC2971. Thanks to Eben Freeman from Nylas. Fix exception with NIL address in envelope address list ------------------------------------------------------- Thanks to Thomas Steinacher for this fix. Fixed handling of NIL in SEARCH response ---------------------------------------- Fixed a regression in the handling of NIL/None SEARCH responses. Thanks again to Thomas Steinacher. Date parsing fixes ------------------ Don't traceback when an unparsable date is seen in ENVELOPE responses. None is returned instead. Support quirky timestamp strings which use dots for the time separator. Removed horrible INTERNALDATE parsing code (use parse_to_datetime instead). datetime_to_imap has been moved to the datetime_util module and is now called datetime_to_INTERNALDATE. This will only affect you in the unlikely case that you were importing this function out of the IMAPClient package. Other ----- - The docs for various IMAPClient methods, and the HACKING.rst file have been updated. - CONDSTORE live test is now more reliable (especially when running against Gmail) ============== Version 0.12 ============== Fixed unicode handling [API CHANGE] ----------------------------------- During the work to support Python 3, IMAPClient was changed to do return unicode for most responses. This was a bad decision, especially because it effectively breaks content that uses multiple encodings (e.g. RFC822 responses). This release includes major changes so that most responses are returned as bytes (Python 3) or str (Python 2). This means that correct handling of response data is now possible by code using IMAPClient. Folder name handling has also been cleaned up as part of this work. If the ``folder_encode`` attribute is ``True`` (the default) then folder names will **always** be returned as unicode. If ``folder_encode`` is False then folder names will always be returned as bytes/strs. Code using IMAPClient will most likely need to be updated to account these unicode handling changes. Many thanks to Inbox (now Nilas, https://nilas.com/) for sponsoring this work. Extra __init__ keyword args are passed through [NEW] ---------------------------------------------------- Any unused keyword arguments passed to the IMAPClient initialiser will now be passed through to the underlying imaplib IMAP4, IMAP4_SSL or IMAP4_stream class. This is specifically to allow the use of imaplib features that control certificate validation (if available with the version of Python being used). Thanks to Chris Arndt for this change. MODSEQ parts in SEARCH responses are now handled ------------------------------------------------ If the CONDSTORE extension is supported by a server and a MODSEQ criteria was used with search(), a TypeError could occur. This has now been fixed and the MODSEQ value returned by the server is now available via an attribute on the returned list of ids. Minor Changes ------------- * Small tweaks to support Python 3.4. * The deprecated get_folder_delimiter() method has been removed. * More control over OAUTH2 parameters. Thanks to Phil Peterson for this. * Fixed livetest/interact OAUTH handling under Python 3. ================ Version 0.11.1 ================ * Close folders during livetest cleanup so that livetests work with newer Dovecot servers (#131) ============== Version 0.11 ============== Support for raw Gmail searching [NEW] ------------------------------------- The new gmail_search methods allows direct Gmail queries using the X-GM-RAW search extension. Thanks to John Louis del Rosario for the patch. ENVELOPE FETCH response parsing [NEW, API CHANGE] ------------------------------------------------- ENVELOPE FETCH responses are now returned as Envelope instances. These objects are namedtuples providing convenient attribute and positional based access to envelope fields. The Date field is also now converted to a datetime instance. As part of this change various date and time related utilities were moved to a new module at imapclient.datetime_util. Thanks to Naveen Nathan for the work on this feature. Correct nested BODYSTRUCTURE handling [API CHANGE] -------------------------------------------------- BODY and BODYSTRUCTURE responses are now processed recusively so multipart sections within other multipart sections are returned correctly. This also means that each the part of the response now has a is_multipart property available. NOTE: code that expects the old (broken) behaviour will need to be updated. Thanks to Brandon Rhodes for the bug report. SELECT response bug fix ----------------------- Handle square brackets in flags returned in SELECT response. Previously these would cause parsing errors. Thanks to Benjamin Morrise for the bug report. Minor Changes ------------- Copyright date update for 2014. ================ Version 0.10.2 ================ Switch back to setuptools now that distribute and setuptools have merged back. Some users were reporting problems with distribute and the newer versions of setuptools. ================ Version 0.10.1 ================ Fixed regressions in several cases when binary data (i.e. normal strings under Python 2) are used as arguments to some methods. Also refactored input normalisation functions somewhat. Fixed buggy method for extracting flags and Gmail labels from STORE responses. ============== Version 0.10 ============== Python 3 support (#22) [API CHANGE] ----------------------------------- Python 3.2 and 3.3 are now officially supported. This release also means that Python versions older than 2.6 are no longer supported. A single source approach has been used, with no conversion step required. A big thank you to Mathieu Agopian for his massive contribution to getting the Python 3 port finished. His changes and ideas feature heavily in this release. **IMPORTANT**: Under Python 2, all strings returned by IMAPClient are now returned as unicode objects. With the exception of folder names, these unicode objects will only contain characters in the ASCII range so this shouldn't break existing code, however there is always a chance that there will be a problem. Please test your existing applications thoroughly with this verison of IMAPClient before deploying to production situations. Minor Changes ------------- * "python setup.py test" now runs the unit tests * Mock library is now longer included (listed as external test dependency) * live tests that aren't UID related are now only run once * live tests now perform far less logins to the server under test * Unit tests can now be run for all supported Python versions using ``tox``. * Improved documentation regarding working on the project. * Many documentation fixes and improvements. Minor Bug Fixes --------------- * HIGHESTMODSEQ in SELECT response is now parsed correctly * Fixed daylight saving handling in FixedOffset class * Fixed --port command line bug in imapclient.interact when SSL connections are made. =============== Version 0.9.2 =============== THREAD support [NEW] -------------------- The IMAP THREAD command is now supported. Thanks to Lukasz Mierzwa for the patches. Enhanced capability querying [NEW] ---------------------------------- Previously only the pre-authentication server capabilities were returned by the capabilities() method. Now, if the connection is authenticated, the post-authentication capabilities will be returned. If the server sent an untagged CAPABILITY response after authentication, that will be used, avoiding an unnecessary CAPABILITY command call. All this ensures that the client sees all available server capabilities. Minor Features -------------- * Better documentation for contributers (see HACKING file) * Copyright date update for 2013. =============== Version 0.9.1 =============== Stream support [NEW] -------------------- It is now possible to have IMAPClient run an external command to establish a connection to the IMAP server via a new *stream* keyword argument to the initialiser. This is useful for exotic connection or authentication setups. The *host* argument is used as the command to run. Thanks to Dave Eckhardt for the original patch. OAUTH2 Support [NEW] -------------------- OAUTH2 authentication (as supported by Gmail's IMAP) is now available via the new oauth2_login method. Thanks to Zac Witte for the original patch. livetest now handles Gmail's new message handling ------------------------------------------------- Gmail's IMAP implementation recently started requiring a NOOP command before new messages become visible after delivery or an APPEND. The livetest suite has been updated to deal with this. ============= Version 0.9 ============= Gmail Label Support ------------------- New methods have been added for interacting with Gmail's label API: get_gmail_labels, add_gmail_labels, set_gmail_labels, remove_gmail_labels. Thanks to Brian Neal for the patches. Removed Code Duplication (#9) ----------------------------- A signficant amount of duplicated code has been removed by abstracting out common command handling code. This will make the Python 3 port and future maintenance easier. livetest can now be run against non-dummy accounts (#108) --------------------------------------------------------- Up until this release the tests in imapclient.livetest could only be run against a dummy IMAP account (all data in the account would be lost during testing). The tests are now limited to a sub-folder created by the tests so it is ok to run them against an account that contains real messages. These messages will be left alone. Minor Features -------------- * Don't traceback when an IMAP server returns a all-digit folder name without quotes. Thanks to Rhett Garber for the bug report. (#107) * More tests for ACL related methods (#89) * More tests for namespace() * Added test for read-only select_folder() Minor Bug Fixes --------------- * Fixed rename live test so that it uses folder namespaces (#100). * Parse STATUS responses robustly - fixes folder_status() with MS Exchange. * Numerous livetest fixes to work around oddities with the MS Exchange IMAP implementation. =============== Version 0.8.1 =============== * IMAPClient wasn't installing on Windows due to an extra trailing slash in MANIFEST.in (#102). This is a bug in distutils. * MANIFEST.in was fixed so that the main documentation index file is included the source distribution. * distribute_setup.py was updated to the 0.6.24 version. * This release also contains some small documentation fixes. ============= Version 0.8 ============= OAUTH Support (#54) [NEW] ------------------------- OAUTH authentication is now supported using the oauth_login method. This requires the 3rd party oauth2 package is installed. Thanks to Johannes Heckel for contributing the patch to this. IDLE Support (#50) [NEW] ------------------------ The IDLE extension is now supported through the new idle(), idle_check() and idle_done() methods. See the example in imapclient/examples/idle_example.py. NOOP Support (#74) [NEW] ------------------------ The NOOP command is now supported. It returns parsed untagged server responses in the same format as idle_check() and idle_done(). Sphinx Based Docs (#5) [NEW] ---------------------------- Full documentation is now available under doc/html in the source distribution and at http://imapclient.readthedocs.io/ online. Added rename_folder (#77) [NEW] -------------------------------- Renaming of folders was an obvious omission! Minor Features -------------- * interact.py can now read livetest.py INI files (#66) * interact.py can now embed shells from ipython 0.10 and 0.11 (#98) * interact.py and livetest.py are now inside the imapclient package so they can be used even when IMAClient has been installed from PyPI (#82) * Added "debug" propety and setting of a log file (#90) * "normalise_times" attribute allows caller to select whether datetimes returned by fetch() are native or not (#96) (Thanks Andrew Scheller) * Added imapclient.version_info - a tuple that contains the IMAPClient version number broken down into it's parts. Minor Bug Fixes --------------- * getacl() was using wrong lexing class (#85) (Thanks josephhh) * Removed special handling for response tuples without whitespace between them. Post-process BODY/BODYSTRUCTURE responses instead. This should not affect the external API. (#91) (Thanks daishi) * Fix incorrect msg_id for UID fetch when use_uid is False (#99) ============= Version 0.7 ============= BODY and BODYSTRUCTURE parsing fixes (#58) [API CHANGE] ------------------------------------------------------- The response values for BODY and BODYSTRUCTURE responses may include a sequence of tuples which are not separated by whitespace. These should be treated as a single item (a list of multiple arbitrarily nested tuples) but IMAPClient was treating them as separate items. IMAPClient now returns these tuples in a list to allow for consistent parsing. A BODYSTRUCTURE response for a multipart email with 2 parts would have previously looked something like this:: (('text', 'html', ('charset', 'us-ascii'), None, None, 'quoted-printable', 55, 3), ('text', 'plain', ('charset', 'us-ascii'), None, None, '7bit', 26, 1), 'mixed', ('boundary', '===============1534046211==')) The response is now returned like this:: ([ ('text', 'html', ('charset', 'us-ascii'), None, None, 'quoted-printable', 55, 3), ('text', 'plain', ('charset', 'us-ascii'), None, None, '7bit', 26, 1) ], 'mixed', ('boundary', '===============1534046211==')) The behaviour for single part messages is unchanged. In this case the first element of the tuple is a string specifying the major content type of the message (eg "text"). An is_multipart boolean property now exists on BODY and BODYSTRUCTURE responses to allow the caller to easily determine whether the response is for a multipart message. Code that expects the previous response handling behaviour needs to be updated. Live tests converted to use unittest2 (#4) ------------------------------------------ livetest.py now uses the unittest2 package to run the tests. This provides much more flexibility that the custom approach that was used before. Dependencies between tests are gone - each test uses a fresh IMAP connection and is preceeded by the same setup. unittest2.main() is used to provide a number of useful command line options and the ability to run a subset of tests. IMAP account parameters are now read using a configuration file instead of command line arguments. See livetest-sample.ini for an example. Added NAMESPACE support (#63) [API CHANGE] ------------------------------------------ namespace() method added and get_folder_delimiter() has been deprecated. Added support for FETCH modifiers (#62) [NEW] --------------------------------------------- The fetch method now takes optional modifiers as the last argument. These are required for extensions such as RFC 4551 (conditional store). Thanks to Thomas Jost for the patch. =============== Version 0.6.2 =============== Square brackets in responses now parsed correctly (#55) ------------------------------------------------------- This fixes response handling for FETCH items such as ``BODY[HEADER.FIELDS (from subject)]``. Example moved (#56) ------------------- The example has been moved to imapclient/examples directory and is included when the IMAPClient is installed from PyPI. Distribute (#57) ---------------- The project is now packaged using Distribute instead of setuptools. There should be no real functional change. =============== Version 0.6.1 =============== Python SSL bug patch -------------------- Automatically patch a bug in imaplib which can cause hangs when using SSL (Python Issue 5949). The patch is only applied when the running Python version is known to be affected by the problem. Doc update ---------- Updated the README to better reflect the current state of the project. ============= Version 0.6 ============= New response parser (#1, #45) ----------------------------- Command response lexing and parsing code rewritten from stratch to deal with various bugs that surfaced when dealing with more complex responses (eg. BODYSTRUCTURE and ENVELOPE). This change also fixes various problems when interacting with Gmail and MS Exchange. XLIST extension support (#25) [NEW] ----------------------------------- Where the server supports it, xlist_folders() will return a mapping of various common folder names to the actual server folder names. Gmail's IMAP server supports this. Added COPY command support (#36) [NEW] -------------------------------------- New copy() method. Added interact.py [NEW] ----------------------- A script for interactive IMAPClient sessions. Useful for debugging and exploration. Uses IPython if installed. Full SELECT response (#24) [API CHANGE] --------------------------------------- select_folder() now returns a dictionary with the full (parsed) SELECT command response instead of just the message count. Full list responses (#24) [API CHANGE] -------------------------------------- The return value from list_folders(), list_sub_folders() and xlist_folders() now include the IMAP folder flags and delimiter. Folder name character encoding (#21) [API CHANGE] ------------------------------------------------- Bytes that are greater than 0x7f in folder names are will cause an exception when passed to methods that accept folder name arguments because there is no unambigous way to handle these. Callers should encode such folder names to unicode objects first. Folder names are now always returned as unicode objects. Message sequence number now always returned in FETCH responses -------------------------------------------------------------- Fetch responses now include a "SEQ" element which gives the message (non-UID) sequence number. This allows for easy mapping between UIDs and standard sequence IDs. Folder name handling fixes (#28, #42) ------------------------------------- Various folder name handling bugs fixed. =============== Version 0.5.2 =============== Folder name quoting and escaping fixes (#28) -------------------------------------------- Correctly handle double quotes and backslashes in folder names when parsing LIST and LSUB responses. Fixed fetch literal handling (#33) ---------------------------------- Fixed problem with parsing responses where a literal followed another literal. =============== Version 0.5.1 =============== License change -------------- Changed license from GPL to new BSD. ============= Version 0.5 ============= SSL support ----------- Support for SSL based connections by passing ssl=True when constructing an IMAPClient instance. Transparent folder encoding --------------------------- Folder names are now encoded and decoded transparently if required (using modified UTF-7). This means that any methods that return folder names may return unicode objects as well as normal strings [API CHANGE]. Additionally, any method that takes a folder name now accepts unicode object too. Use the folder_encode attribute to control whether encode/decoding is performed. Unquoted folder name handling fix --------------------------------- Unquoted folder names in server responses are now handled correctly. Thanks to Neil Martinsen-Burrell for reporting this bug. Fixed handling of unusual characters in folder names ---------------------------------------------------- Fixed a bug with handling of unusual characters in folder names. Timezone handling [API CHANGE] ------------------------------ Timezones are now handled correctly for datetimes passed as input and for server responses. This fixes a number of bugs with timezones. Returned datetimes are always in the client's local timezone. More unit tests --------------- Many more unit tests added, some using Michael Foord's excellent mock.py. (http://www.voidspace.org.uk/python/mock/) imapclient-3.0.1/examples/000077500000000000000000000000001453256433100154645ustar00rootroot00000000000000imapclient-3.0.1/examples/email_parsing.py000066400000000000000000000010721453256433100206500ustar00rootroot00000000000000# Download unread emails and parse them into standard EmailMessage objects import email from imapclient import IMAPClient HOST = "imap.host.com" USERNAME = "someuser" PASSWORD = "secret" with IMAPClient(HOST) as server: server.login(USERNAME, PASSWORD) server.select_folder("INBOX", readonly=True) messages = server.search("UNSEEN") for uid, message_data in server.fetch(messages, "RFC822").items(): email_message = email.message_from_bytes(message_data[b"RFC822"]) print(uid, email_message.get("From"), email_message.get("Subject")) imapclient-3.0.1/examples/example.py000066400000000000000000000012771453256433100175000ustar00rootroot00000000000000# List number of messages in INBOX folder # and print details of the messages that are not deleted from imapclient import IMAPClient HOST = "imap.host.com" USERNAME = "someuser" PASSWORD = "secret" server = IMAPClient(HOST) server.login(USERNAME, PASSWORD) select_info = server.select_folder("INBOX") print("%d messages in INBOX" % select_info[b"EXISTS"]) messages = server.search(["NOT", "DELETED"]) print("%d messages that aren't deleted\n" % len(messages)) print("Messages:") response = server.fetch(messages, ["FLAGS", "RFC822.SIZE"]) for msgid, data in response.items(): print( " ID %d: %d bytes, flags=%s" % (msgid, data[b"RFC822.SIZE"], data[b"FLAGS"]) ) server.logout() imapclient-3.0.1/examples/idle_example.py000066400000000000000000000012611453256433100204660ustar00rootroot00000000000000# Open a connection in IDLE mode and wait for notifications from the # server. from imapclient import IMAPClient HOST = "imap.host.com" USERNAME = "someuser" PASSWORD = "password" server = IMAPClient(HOST) server.login(USERNAME, PASSWORD) server.select_folder("INBOX") # Start IDLE mode server.idle() print("Connection is now in IDLE mode, send yourself an email or quit with ^c") while True: try: # Wait for up to 30 seconds for an IDLE response responses = server.idle_check(timeout=30) print("Server sent:", responses if responses else "nothing") except KeyboardInterrupt: break server.idle_done() print("\nIDLE mode done") server.logout() imapclient-3.0.1/examples/idle_selector_example.py000066400000000000000000000023151453256433100223670ustar00rootroot00000000000000from datetime import datetime, timedelta from selectors import DefaultSelector, EVENT_READ from imapclient import IMAPClient HOST = "localhost" USERNAME = "user" PASSWORD = "Tr0ub4dor&3" RESPONSE_TIMEOUT_SECONDS = 15 IDLE_SECONDS = 60 * 24 with IMAPClient(HOST, timeout=RESPONSE_TIMEOUT_SECONDS) as server: server.login(USERNAME, PASSWORD) server.select_folder("INBOX", readonly=True) server.idle() print("Connection is now in IDLE mode," " send yourself an email or quit with ^c") try: with DefaultSelector() as selector: selector.register(server.socket(), EVENT_READ, None) now = datetime.now end_at = now() + timedelta(seconds=IDLE_SECONDS) while selector.select((end_at - now()).total_seconds()): responses = server.idle_check(timeout=0) if not responses: raise ConnectionError( "Socket readable without data. Likely closed." ) print("Server sent:", responses) print("IDLE time out.") except KeyboardInterrupt: print("") # Newline after the typically echoed ^C. server.idle_done() print("IDLE mode done") imapclient-3.0.1/examples/oauth2_example.py000066400000000000000000000005151453256433100207540ustar00rootroot00000000000000# Login using OAUTH2 from imapclient import IMAPClient # Populate these with actual values OAUTH2_USER = "..." OAUTH2_ACCESS_TOKEN = "..." HOST = "imap.host.com" URL = "https://somedomain.com/someuser/imap/" with IMAPClient(HOST) as server: server.oauth2_login(URL, OAUTH2_USER, OAUTH2_ACCESS_TOKEN) # ...do something... imapclient-3.0.1/examples/tls_cacert.py000066400000000000000000000006611453256433100201640ustar00rootroot00000000000000# Establish a secure connection to a server that does not have a certificate # signed by a trusted certificate authority (CA). import ssl from imapclient import IMAPClient HOST = "imap.host.com" USERNAME = "someuser" PASSWORD = "secret" ssl_context = ssl.create_default_context(cafile="/path/to/cacert.pem") with IMAPClient(HOST, ssl_context=ssl_context) as server: server.login(USERNAME, PASSWORD) # ...do something... imapclient-3.0.1/examples/tls_no_checks.py000066400000000000000000000012201453256433100206470ustar00rootroot00000000000000# Establish an encrypted connection to a server without checking its # certificate. This setup is insecure, DO NOT USE to connect to servers # over the Internet. import ssl from imapclient import IMAPClient HOST = "imap.host.com" USERNAME = "someuser" PASSWORD = "secret" ssl_context = ssl.create_default_context() # don't check if certificate hostname doesn't match target hostname ssl_context.check_hostname = False # don't check if the certificate is trusted by a certificate authority ssl_context.verify_mode = ssl.CERT_NONE with IMAPClient(HOST, ssl_context=ssl_context) as server: server.login(USERNAME, PASSWORD) # ...do something... imapclient-3.0.1/imapclient/000077500000000000000000000000001453256433100157735ustar00rootroot00000000000000imapclient-3.0.1/imapclient/__init__.py000066400000000000000000000010471453256433100201060ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses # version_info provides the version number in programmer friendly way. # The 4th part will be either alpha, beta or final. from .imapclient import * # noqa: F401,F403 from .response_parser import * # noqa: F401,F403 from .tls import * # noqa: F401,F403 from .version import author as __author__ # noqa: F401 from .version import version as __version__ # noqa: F401 from .version import version_info # noqa: F401 imapclient-3.0.1/imapclient/config.py000066400000000000000000000147031453256433100176170ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import argparse import configparser import json import os import ssl import urllib.parse import urllib.request from typing import Any, Callable, Dict, Optional, Tuple, TYPE_CHECKING, TypeVar import imapclient def getenv(name: str, default: Optional[str]) -> Optional[str]: return os.environ.get("imapclient_" + name, default) def get_config_defaults() -> Dict[str, Any]: return { "username": getenv("username", None), "password": getenv("password", None), "ssl": True, "ssl_check_hostname": True, "ssl_verify_cert": True, "ssl_ca_file": None, "timeout": None, "starttls": False, "stream": False, "oauth2": False, "oauth2_client_id": getenv("oauth2_client_id", None), "oauth2_client_secret": getenv("oauth2_client_secret", None), "oauth2_refresh_token": getenv("oauth2_refresh_token", None), "expect_failure": None, } def parse_config_file(filename: str) -> argparse.Namespace: """Parse INI files containing IMAP connection details. Used by livetest.py and interact.py """ parser = configparser.ConfigParser(get_string_config_defaults()) parser.read(filename) conf = _read_config_section(parser, "DEFAULT") if conf.expect_failure: raise ValueError("expect_failure should not be set for the DEFAULT section") conf.alternates = {} for section in parser.sections(): # pylint: disable=no-member conf.alternates[section] = _read_config_section(parser, section) return conf def get_string_config_defaults() -> Dict[str, str]: out = {} for k, v in get_config_defaults().items(): if v is True: v = "true" elif v is False: v = "false" elif not v: v = "" out[k] = v return out T = TypeVar("T") def _read_config_section( parser: configparser.ConfigParser, section: str ) -> argparse.Namespace: def get(name: str) -> str: return parser.get(section, name) def getboolean(name: str) -> bool: return parser.getboolean(section, name) def get_allowing_none(name: str, typefunc: Callable[[str], T]) -> Optional[T]: try: v = parser.get(section, name) except configparser.NoOptionError: return None if not v: return None return typefunc(v) def getint(name: str) -> Optional[int]: return get_allowing_none(name, int) def getfloat(name: str) -> Optional[float]: return get_allowing_none(name, float) ssl_ca_file = get("ssl_ca_file") if ssl_ca_file: ssl_ca_file = os.path.expanduser(ssl_ca_file) return argparse.Namespace( host=get("host"), port=getint("port"), ssl=getboolean("ssl"), starttls=getboolean("starttls"), ssl_check_hostname=getboolean("ssl_check_hostname"), ssl_verify_cert=getboolean("ssl_verify_cert"), ssl_ca_file=ssl_ca_file, timeout=getfloat("timeout"), stream=getboolean("stream"), username=get("username"), password=get("password"), oauth2=getboolean("oauth2"), oauth2_client_id=get("oauth2_client_id"), oauth2_client_secret=get("oauth2_client_secret"), oauth2_refresh_token=get("oauth2_refresh_token"), expect_failure=get("expect_failure"), ) OAUTH2_REFRESH_URLS = { "imap.gmail.com": "https://accounts.google.com/o/oauth2/token", "imap.mail.yahoo.com": "https://api.login.yahoo.com/oauth2/get_token", } def refresh_oauth2_token( hostname: str, client_id: str, client_secret: str, refresh_token: str ) -> str: url = OAUTH2_REFRESH_URLS.get(hostname) if not url: raise ValueError("don't know where to refresh OAUTH2 token for %r" % hostname) post = { "client_id": client_id.encode("ascii"), "client_secret": client_secret.encode("ascii"), "refresh_token": refresh_token.encode("ascii"), "grant_type": b"refresh_token", } with urllib.request.urlopen( url, urllib.parse.urlencode(post).encode("ascii") ) as request: response = request.read() result = json.loads(response.decode("ascii"))["access_token"] if TYPE_CHECKING: assert isinstance(result, str) return result # Tokens are expensive to refresh so use the same one for the duration of the process. _oauth2_cache: Dict[Tuple[str, str, str, str], str] = {} def get_oauth2_token( hostname: str, client_id: str, client_secret: str, refresh_token: str ) -> str: cache_key = (hostname, client_id, client_secret, refresh_token) token = _oauth2_cache.get(cache_key) if token: return token token = refresh_oauth2_token(hostname, client_id, client_secret, refresh_token) _oauth2_cache[cache_key] = token return token def create_client_from_config( conf: argparse.Namespace, login: bool = True ) -> imapclient.IMAPClient: assert conf.host, "missing host" ssl_context = None if conf.ssl: ssl_context = ssl.create_default_context() ssl_context.check_hostname = conf.ssl_check_hostname if not conf.ssl_verify_cert: ssl_context.verify_mode = ssl.CERT_NONE if conf.ssl_ca_file: ssl_context.load_verify_locations(cafile=conf.ssl_ca_file) client = imapclient.IMAPClient( conf.host, port=conf.port, ssl=conf.ssl, ssl_context=ssl_context, stream=conf.stream, timeout=conf.timeout, ) if not login: return client try: if conf.starttls: client.starttls() if conf.oauth2: assert conf.oauth2_client_id, "missing oauth2 id" assert conf.oauth2_client_secret, "missing oauth2 secret" assert conf.oauth2_refresh_token, "missing oauth2 refresh token" access_token = get_oauth2_token( conf.host, conf.oauth2_client_id, conf.oauth2_client_secret, conf.oauth2_refresh_token, ) client.oauth2_login(conf.username, access_token) elif not conf.stream: assert conf.username, "missing username" assert conf.password, "missing password" client.login(conf.username, conf.password) return client except: # noqa: E722 client.shutdown() raise imapclient-3.0.1/imapclient/datetime_util.py000066400000000000000000000043541453256433100212040ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import re from datetime import datetime from email.utils import parsedate_tz from .fixed_offset import FixedOffset _SHORT_MONTHS = " Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ") def parse_to_datetime(timestamp: bytes, normalise: bool = True) -> datetime: """Convert an IMAP datetime string to a datetime. If normalise is True (the default), then the returned datetime will be timezone-naive but adjusted to the local time. If normalise is False, then the returned datetime will be unadjusted but will contain timezone information as per the input. """ time_tuple = parsedate_tz(_munge(timestamp)) if time_tuple is None: raise ValueError("couldn't parse datetime %r" % timestamp) tz_offset_seconds = time_tuple[-1] tz = None if tz_offset_seconds is not None: tz = FixedOffset(tz_offset_seconds / 60) dt = datetime(*time_tuple[:6], tzinfo=tz) if normalise and tz: dt = datetime_to_native(dt) return dt def datetime_to_native(dt: datetime) -> datetime: return dt.astimezone(FixedOffset.for_system()).replace(tzinfo=None) def datetime_to_INTERNALDATE(dt: datetime) -> str: """Convert a datetime instance to a IMAP INTERNALDATE string. If timezone information is missing the current system timezone is used. """ if not dt.tzinfo: dt = dt.replace(tzinfo=FixedOffset.for_system()) fmt = "%d-" + _SHORT_MONTHS[dt.month] + "-%Y %H:%M:%S %z" return dt.strftime(fmt) # Matches timestamp strings where the time separator is a dot (see # issue #154). For example: 'Sat, 8 May 2010 16.03.09 +0200' _rfc822_dotted_time = re.compile(r"\w+, ?\d{1,2} \w+ \d\d(\d\d)? \d\d?\.\d\d?\.\d\d?.*") def _munge(timestamp: bytes) -> str: s = timestamp.decode("latin-1") # parsedate_tz only works with strings if _rfc822_dotted_time.match(s): return s.replace(".", ":") return s def format_criteria_date(dt: datetime) -> bytes: """Format a date or datetime instance for use in IMAP search criteria.""" out = "%02d-%s-%d" % (dt.day, _SHORT_MONTHS[dt.month], dt.year) return out.encode("ascii") imapclient-3.0.1/imapclient/exceptions.py000066400000000000000000000022511453256433100205260ustar00rootroot00000000000000import imaplib # Base class allowing to catch any IMAPClient related exceptions # To ensure backward compatibility, we "rename" the imaplib general # exception class, so we can catch its exceptions without having to # deal with it in IMAPClient codebase IMAPClientError = imaplib.IMAP4.error IMAPClientAbortError = imaplib.IMAP4.abort IMAPClientReadOnlyError = imaplib.IMAP4.readonly class CapabilityError(IMAPClientError): """ The command tried by the user needs a capability not installed on the IMAP server """ class LoginError(IMAPClientError): """ A connection has been established with the server but an error occurred during the authentication. """ class IllegalStateError(IMAPClientError): """ The command tried needs a different state to be executed. This means the user is not logged in or the command needs a folder to be selected. """ class InvalidCriteriaError(IMAPClientError): """ A command using a search criteria failed, probably due to a syntax error in the criteria string. """ class ProtocolError(IMAPClientError): """The server replied with a response that violates the IMAP protocol.""" imapclient-3.0.1/imapclient/fixed_offset.py000066400000000000000000000024221453256433100210120ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import datetime import time from typing import Optional ZERO = datetime.timedelta(0) class FixedOffset(datetime.tzinfo): """ This class describes fixed timezone offsets in hours and minutes east from UTC """ def __init__(self, minutes: float) -> None: self.__offset = datetime.timedelta(minutes=minutes) sign = "+" if minutes < 0: sign = "-" hours, remaining_mins = divmod(abs(minutes), 60) self.__name = "%s%02d%02d" % (sign, hours, remaining_mins) def utcoffset(self, _: Optional[datetime.datetime]) -> datetime.timedelta: return self.__offset def tzname(self, _: Optional[datetime.datetime]) -> str: return self.__name def dst(self, _: Optional[datetime.datetime]) -> datetime.timedelta: return ZERO @classmethod def for_system(cls) -> "FixedOffset": """Return a FixedOffset instance for the current working timezone and DST conditions. """ if time.localtime().tm_isdst and time.daylight: offset = time.altzone else: offset = time.timezone return cls(-offset // 60) imapclient-3.0.1/imapclient/imap4.py000066400000000000000000000016571453256433100173700ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import imaplib import socket from typing import Optional class IMAP4WithTimeout(imaplib.IMAP4): def __init__(self, address: str, port: int, timeout: Optional[float]) -> None: self._timeout = timeout imaplib.IMAP4.__init__(self, address, port) def open( self, host: str = "", port: int = 143, timeout: Optional[float] = None ) -> None: # This is overridden to make it consistent across Python versions. self.host = host self.port = port self.sock = self._create_socket(timeout) self.file = self.sock.makefile("rb") def _create_socket(self, timeout: Optional[float] = None) -> socket.socket: return socket.create_connection( (self.host, self.port), timeout if timeout is not None else self._timeout ) imapclient-3.0.1/imapclient/imap_utf7.py000066400000000000000000000071671453256433100202530ustar00rootroot00000000000000# This file contains two main methods used to encode and decode UTF-7 # string, described in the RFC 3501. There are some variations specific # to IMAP4rev1, so the built-in Python UTF-7 codec can't be used instead. # # The main difference is the shift character (used to switch from ASCII to # base64 encoding context), which is & in this modified UTF-7 convention, # since + is considered as mainly used in mailbox names. # Other variations and examples can be found in the RFC 3501, section 5.1.3. import binascii from typing import List, Union def encode(s: Union[str, bytes]) -> bytes: """Encode a folder name using IMAP modified UTF-7 encoding. Input is unicode; output is bytes (Python 3) or str (Python 2). If non-unicode input is provided, the input is returned unchanged. """ if not isinstance(s, str): return s res = bytearray() b64_buffer: List[str] = [] def consume_b64_buffer(buf: List[str]) -> None: """ Consume the buffer by encoding it into a modified base 64 representation and surround it with shift characters & and - """ if buf: res.extend(b"&" + base64_utf7_encode(buf) + b"-") del buf[:] for c in s: # printable ascii case should not be modified o = ord(c) if 0x20 <= o <= 0x7E: consume_b64_buffer(b64_buffer) # Special case: & is used as shift character so we need to escape it in ASCII if o == 0x26: # & = 0x26 res.extend(b"&-") else: res.append(o) # Bufferize characters that will be encoded in base64 and append them later # in the result, when iterating over ASCII character or the end of string else: b64_buffer.append(c) # Consume the remaining buffer if the string finish with non-ASCII characters consume_b64_buffer(b64_buffer) return bytes(res) AMPERSAND_ORD = ord("&") DASH_ORD = ord("-") def decode(s: Union[bytes, str]) -> str: """Decode a folder name from IMAP modified UTF-7 encoding to unicode. Input is bytes (Python 3) or str (Python 2); output is always unicode. If non-bytes/str input is provided, the input is returned unchanged. """ if not isinstance(s, bytes): return s res = [] # Store base64 substring that will be decoded once stepping on end shift character b64_buffer = bytearray() for c in s: # Shift character without anything in buffer -> starts storing base64 substring if c == AMPERSAND_ORD and not b64_buffer: b64_buffer.append(c) # End shift char. -> append the decoded buffer to the result and reset it elif c == DASH_ORD and b64_buffer: # Special case &-, representing "&" escaped if len(b64_buffer) == 1: res.append("&") else: res.append(base64_utf7_decode(b64_buffer[1:])) b64_buffer = bytearray() # Still buffering between the shift character and the shift back to ASCII elif b64_buffer: b64_buffer.append(c) # No buffer initialized yet, should be an ASCII printable char else: res.append(chr(c)) # Decode the remaining buffer if any if b64_buffer: res.append(base64_utf7_decode(b64_buffer[1:])) return "".join(res) def base64_utf7_encode(buffer: List[str]) -> bytes: s = "".join(buffer).encode("utf-16be") return binascii.b2a_base64(s).rstrip(b"\n=").replace(b"/", b",") def base64_utf7_decode(s: bytearray) -> str: s_utf7 = b"+" + s.replace(b",", b"/") + b"-" return s_utf7.decode("utf-7") imapclient-3.0.1/imapclient/imapclient.py000066400000000000000000002174331453256433100205040ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import dataclasses import functools import imaplib import itertools import re import select import socket import ssl as ssl_lib import sys import warnings from datetime import date, datetime from logging import getLogger, LoggerAdapter from operator import itemgetter from typing import List, Optional from . import exceptions, imap4, response_lexer, tls from .datetime_util import datetime_to_INTERNALDATE, format_criteria_date from .imap_utf7 import decode as decode_utf7 from .imap_utf7 import encode as encode_utf7 from .response_parser import parse_fetch_response, parse_message_list, parse_response from .util import assert_imap_protocol, chunk, to_bytes, to_unicode if hasattr(select, "poll"): POLL_SUPPORT = True else: # Fallback to select() on systems that don't support poll() POLL_SUPPORT = False logger = getLogger(__name__) __all__ = [ "IMAPClient", "SocketTimeout", "DELETED", "SEEN", "ANSWERED", "FLAGGED", "DRAFT", "RECENT", ] # We also offer the gmail-specific XLIST command... if "XLIST" not in imaplib.Commands: imaplib.Commands["XLIST"] = ("NONAUTH", "AUTH", "SELECTED") # ...and IDLE if "IDLE" not in imaplib.Commands: imaplib.Commands["IDLE"] = ("NONAUTH", "AUTH", "SELECTED") # ..and STARTTLS if "STARTTLS" not in imaplib.Commands: imaplib.Commands["STARTTLS"] = ("NONAUTH",) # ...and ID. RFC2971 says that this command is valid in all states, # but not that some servers (*cough* FastMail *cough*) don't seem to # accept it in state NONAUTH. if "ID" not in imaplib.Commands: imaplib.Commands["ID"] = ("NONAUTH", "AUTH", "SELECTED") # ... and UNSELECT. RFC3691 does not specify the state but there is no # reason to use the command without AUTH state and a mailbox selected. if "UNSELECT" not in imaplib.Commands: imaplib.Commands["UNSELECT"] = ("AUTH", "SELECTED") # .. and ENABLE. if "ENABLE" not in imaplib.Commands: imaplib.Commands["ENABLE"] = ("AUTH",) # .. and MOVE for RFC6851. if "MOVE" not in imaplib.Commands: imaplib.Commands["MOVE"] = ("AUTH", "SELECTED") # System flags DELETED = rb"\Deleted" SEEN = rb"\Seen" ANSWERED = rb"\Answered" FLAGGED = rb"\Flagged" DRAFT = rb"\Draft" RECENT = rb"\Recent" # This flag is read-only # Special folders, see RFC6154 # \Flagged is omitted because it is the same as the flag defined above ALL = rb"\All" ARCHIVE = rb"\Archive" DRAFTS = rb"\Drafts" JUNK = rb"\Junk" SENT = rb"\Sent" TRASH = rb"\Trash" # Personal namespaces that are common among providers # used as a fallback when the server does not support the NAMESPACE capability _POPULAR_PERSONAL_NAMESPACES = (("", ""), ("INBOX.", ".")) # Names of special folders that are common among providers _POPULAR_SPECIAL_FOLDERS = { SENT: ("Sent", "Sent Items", "Sent items"), DRAFTS: ("Drafts",), ARCHIVE: ("Archive",), TRASH: ("Trash", "Deleted Items", "Deleted Messages", "Deleted"), JUNK: ("Junk", "Spam"), } _RE_SELECT_RESPONSE = re.compile(rb"\[(?P[A-Z-]+)( \((?P.*)\))?\]") class Namespace(tuple): def __new__(cls, personal, other, shared): return tuple.__new__(cls, (personal, other, shared)) personal = property(itemgetter(0)) other = property(itemgetter(1)) shared = property(itemgetter(2)) @dataclasses.dataclass class SocketTimeout: """Represents timeout configuration for an IMAP connection. :ivar connect: maximum time to wait for a connection attempt to remote server :ivar read: maximum time to wait for performing a read/write operation As an example, ``SocketTimeout(connect=15, read=60)`` will make the socket timeout if the connection takes more than 15 seconds to establish but read/write operations can take up to 60 seconds once the connection is done. """ connect: float read: float @dataclasses.dataclass class MailboxQuotaRoots: """Quota roots associated with a mailbox. Represents the response of a GETQUOTAROOT command. :ivar mailbox: the mailbox :ivar quota_roots: list of quota roots associated with the mailbox """ mailbox: str quota_roots: List[str] @dataclasses.dataclass class Quota: """Resource quota. Represents the response of a GETQUOTA command. :ivar quota_roots: the quota roots for which the limit apply :ivar resource: the resource being limited (STORAGE, MESSAGES...) :ivar usage: the current usage of the resource :ivar limit: the maximum allowed usage of the resource """ quota_root: str resource: str usage: bytes limit: bytes def require_capability(capability): """Decorator raising CapabilityError when a capability is not available.""" def actual_decorator(func): @functools.wraps(func) def wrapper(client, *args, **kwargs): if not client.has_capability(capability): raise exceptions.CapabilityError( "Server does not support {} capability".format(capability) ) return func(client, *args, **kwargs) return wrapper return actual_decorator class IMAPClient: """A connection to the IMAP server specified by *host* is made when this class is instantiated. *port* defaults to 993, or 143 if *ssl* is ``False``. If *use_uid* is ``True`` unique message UIDs be used for all calls that accept message ids (defaults to ``True``). If *ssl* is ``True`` (the default) a secure connection will be made. Otherwise an insecure connection over plain text will be established. If *ssl* is ``True`` the optional *ssl_context* argument can be used to provide an ``ssl.SSLContext`` instance used to control SSL/TLS connection parameters. If this is not provided a sensible default context will be used. If *stream* is ``True`` then *host* is used as the command to run to establish a connection to the IMAP server (defaults to ``False``). This is useful for exotic connection or authentication setups. Use *timeout* to specify a timeout for the socket connected to the IMAP server. The timeout can be either a float number, or an instance of :py:class:`imapclient.SocketTimeout`. * If a single float number is passed, the same timeout delay applies during the initial connection to the server and for all future socket reads and writes. * In case of a ``SocketTimeout``, connection timeout and read/write operations can have distinct timeouts. * The default is ``None``, where no timeout is used. The *normalise_times* attribute specifies whether datetimes returned by ``fetch()`` are normalised to the local system time and include no timezone information (native), or are datetimes that include timezone information (aware). By default *normalise_times* is True (times are normalised to the local system time). This attribute can be changed between ``fetch()`` calls if required. Can be used as a context manager to automatically close opened connections: >>> with IMAPClient(host="imap.foo.org") as client: ... client.login("bar@foo.org", "passwd") """ # Those exceptions are kept for backward-compatibility, since # previous versions included these attributes as references to # imaplib original exceptions Error = exceptions.IMAPClientError AbortError = exceptions.IMAPClientAbortError ReadOnlyError = exceptions.IMAPClientReadOnlyError def __init__( self, host: str, port: int = None, use_uid: bool = True, ssl: bool = True, stream: bool = False, ssl_context: Optional[ssl_lib.SSLContext] = None, timeout: Optional[float] = None, ): if stream: if port is not None: raise ValueError("can't set 'port' when 'stream' True") if ssl: raise ValueError("can't use 'ssl' when 'stream' is True") elif port is None: port = ssl and 993 or 143 if ssl and port == 143: logger.warning( "Attempting to establish an encrypted connection " "to a port (143) often used for unencrypted " "connections" ) self.host = host self.port = port self.ssl = ssl self.ssl_context = ssl_context self.stream = stream self.use_uid = use_uid self.folder_encode = True self.normalise_times = True # If the user gives a single timeout value, assume it is the same for # connection and read/write operations if not isinstance(timeout, SocketTimeout): timeout = SocketTimeout(timeout, timeout) self._timeout = timeout self._starttls_done = False self._cached_capabilities = None self._idle_tag = None self._imap = self._create_IMAP4() logger.debug( "Connected to host %s over %s", self.host, "SSL/TLS" if ssl else "plain text", ) self._set_read_timeout() # Small hack to make imaplib log everything to its own logger imaplib_logger = IMAPlibLoggerAdapter(getLogger("imapclient.imaplib"), {}) self._imap.debug = 5 self._imap._mesg = imaplib_logger.debug def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): """Logout and closes the connection when exiting the context manager. All exceptions during logout and connection shutdown are caught because an error here usually means the connection was already closed. """ try: self.logout() except Exception: try: self.shutdown() except Exception as e: logger.info("Could not close the connection cleanly: %s", e) def _create_IMAP4(self): if self.stream: return imaplib.IMAP4_stream(self.host) connect_timeout = getattr(self._timeout, "connect", None) if self.ssl: return tls.IMAP4_TLS( self.host, self.port, self.ssl_context, connect_timeout, ) return imap4.IMAP4WithTimeout(self.host, self.port, connect_timeout) def _set_read_timeout(self): if self._timeout is not None: self.socket().settimeout(self._timeout.read) @property def _sock(self): warnings.warn("_sock is deprecated. Use socket().", DeprecationWarning) return self.socket() def socket(self): """Returns socket used to connect to server. The socket is provided for polling purposes only. It can be used in, for example, :py:meth:`selectors.BaseSelector.register` and :py:meth:`asyncio.loop.add_reader` to wait for data. .. WARNING:: All other uses of the returned socket are unsupported. This includes reading from and writing to the socket, as they are likely to break internal bookkeeping of messages. """ # In py2, imaplib has sslobj (for SSL connections), and sock for non-SSL. # In the py3 version it's just sock. return getattr(self._imap, "sslobj", self._imap.sock) @require_capability("STARTTLS") def starttls(self, ssl_context=None): """Switch to an SSL encrypted connection by sending a STARTTLS command. The *ssl_context* argument is optional and should be a :py:class:`ssl.SSLContext` object. If no SSL context is given, a SSL context with reasonable default settings will be used. You can enable checking of the hostname in the certificate presented by the server against the hostname which was used for connecting, by setting the *check_hostname* attribute of the SSL context to ``True``. The default SSL context has this setting enabled. Raises :py:exc:`Error` if the SSL connection could not be established. Raises :py:exc:`AbortError` if the server does not support STARTTLS or an SSL connection is already established. """ if self.ssl or self._starttls_done: raise exceptions.IMAPClientAbortError("TLS session already established") typ, data = self._imap._simple_command("STARTTLS") self._checkok("starttls", typ, data) self._starttls_done = True self._imap.sock = tls.wrap_socket(self._imap.sock, ssl_context, self.host) self._imap.file = self._imap.sock.makefile("rb") return data[0] def login(self, username: str, password: str): """Login using *username* and *password*, returning the server response. """ try: rv = self._command_and_check( "login", to_unicode(username), to_unicode(password), unpack=True, ) except exceptions.IMAPClientError as e: raise exceptions.LoginError(str(e)) logger.debug("Logged in as %s", username) return rv def oauth2_login( self, user: str, access_token: str, mech: str = "XOAUTH2", vendor: Optional[str] = None, ): """Authenticate using the OAUTH2 or XOAUTH2 methods. Gmail and Yahoo both support the 'XOAUTH2' mechanism, but Yahoo requires the 'vendor' portion in the payload. """ auth_string = "user=%s\1auth=Bearer %s\1" % (user, access_token) if vendor: auth_string += "vendor=%s\1" % vendor auth_string += "\1" try: return self._command_and_check("authenticate", mech, lambda x: auth_string) except exceptions.IMAPClientError as e: raise exceptions.LoginError(str(e)) def oauthbearer_login(self, identity, access_token): """Authenticate using the OAUTHBEARER method. This is supported by Gmail and is meant to supersede the non-standard 'OAUTH2' and 'XOAUTH2' mechanisms. """ # https://tools.ietf.org/html/rfc5801#section-4 # Technically this is the authorization_identity, but at least for Gmail it's # mandatory and practically behaves like the regular username/identity. if identity: gs2_header = "n,a=%s," % identity.replace("=", "=3D").replace(",", "=2C") else: gs2_header = "n,," # https://tools.ietf.org/html/rfc6750#section-2.1 http_authz = "Bearer %s" % access_token # https://tools.ietf.org/html/rfc7628#section-3.1 auth_string = "%s\1auth=%s\1\1" % (gs2_header, http_authz) try: return self._command_and_check( "authenticate", "OAUTHBEARER", lambda x: auth_string ) except exceptions.IMAPClientError as e: raise exceptions.LoginError(str(e)) def plain_login(self, identity, password, authorization_identity=None): """Authenticate using the PLAIN method (requires server support).""" if not authorization_identity: authorization_identity = "" auth_string = "%s\0%s\0%s" % (authorization_identity, identity, password) try: return self._command_and_check( "authenticate", "PLAIN", lambda _: auth_string, unpack=True ) except exceptions.IMAPClientError as e: raise exceptions.LoginError(str(e)) def sasl_login(self, mech_name, mech_callable): """Authenticate using a provided SASL mechanism (requires server support). The *mech_callable* will be called with one parameter (the server challenge as bytes) and must return the corresponding client response (as bytes, or as string which will be automatically encoded). It will be called as many times as the server produces challenges, which will depend on the specific SASL mechanism. (If the mechanism is defined as "client-first", the server will nevertheless produce a zero-length challenge.) For example, PLAIN has just one step with empty challenge, so a handler might look like this:: plain_mech = lambda _: "\\0%s\\0%s" % (username, password) imap.sasl_login("PLAIN", plain_mech) A more complex but still stateless handler might look like this:: def example_mech(challenge): if challenge == b"Username:" return username.encode("utf-8") elif challenge == b"Password:" return password.encode("utf-8") else: return b"" imap.sasl_login("EXAMPLE", example_mech) A stateful handler might look like this:: class ScramSha256SaslMechanism(): def __init__(self, username, password): ... def __call__(self, challenge): self.step += 1 if self.step == 1: response = ... elif self.step == 2: response = ... return response scram_mech = ScramSha256SaslMechanism(username, password) imap.sasl_login("SCRAM-SHA-256", scram_mech) """ try: return self._command_and_check( "authenticate", mech_name, mech_callable, unpack=True ) except exceptions.IMAPClientError as e: raise exceptions.LoginError(str(e)) def logout(self): """Logout, returning the server response.""" typ, data = self._imap.logout() self._check_resp("BYE", "logout", typ, data) logger.debug("Logged out, connection closed") return data[0] def shutdown(self) -> None: """Close the connection to the IMAP server (without logging out) In most cases, :py:meth:`.logout` should be used instead of this. The logout method also shutdown down the connection. """ self._imap.shutdown() logger.info("Connection closed") @require_capability("ENABLE") def enable(self, *capabilities): """Activate one or more server side capability extensions. Most capabilities do not need to be enabled. This is only required for extensions which introduce backwards incompatible behaviour. Two capabilities which may require enable are ``CONDSTORE`` and ``UTF8=ACCEPT``. A list of the requested extensions that were successfully enabled on the server is returned. Once enabled each extension remains active until the IMAP connection is closed. See :rfc:`5161` for more details. """ if self._imap.state != "AUTH": raise exceptions.IllegalStateError( "ENABLE command illegal in state %s" % self._imap.state ) resp = self._raw_command_untagged( b"ENABLE", [to_bytes(c) for c in capabilities], uid=False, response_name="ENABLED", unpack=True, ) if not resp: return [] return resp.split() @require_capability("ID") def id_(self, parameters=None): """Issue the ID command, returning a dict of server implementation fields. *parameters* should be specified as a dictionary of field/value pairs, for example: ``{"name": "IMAPClient", "version": "0.12"}`` """ if parameters is None: args = "NIL" else: if not isinstance(parameters, dict): raise TypeError("'parameters' should be a dictionary") args = seq_to_parenstr( _quote(v) for v in itertools.chain.from_iterable(parameters.items()) ) typ, data = self._imap._simple_command("ID", args) self._checkok("id", typ, data) typ, data = self._imap._untagged_response(typ, data, "ID") return parse_response(data) def capabilities(self): """Returns the server capability list. If the session is authenticated and the server has returned an untagged CAPABILITY response at authentication time, this response will be returned. Otherwise, the CAPABILITY command will be issued to the server, with the results cached for future calls. If the session is not yet authenticated, the capabilities requested at connection time will be returned. """ # Ensure cached capabilities aren't used post-STARTTLS. As per # https://tools.ietf.org/html/rfc2595#section-3.1 if self._starttls_done and self._imap.state == "NONAUTH": self._cached_capabilities = None return self._do_capabilites() # If a capability response has been cached, use that. if self._cached_capabilities: return self._cached_capabilities # If the server returned an untagged CAPABILITY response # (during authentication), cache it and return that. untagged = _dict_bytes_normaliser(self._imap.untagged_responses) response = untagged.pop("CAPABILITY", None) if response: self._cached_capabilities = self._normalise_capabilites(response[0]) return self._cached_capabilities # If authenticated, but don't have a capability response, ask for one if self._imap.state in ("SELECTED", "AUTH"): self._cached_capabilities = self._do_capabilites() return self._cached_capabilities # Return capabilities that imaplib requested at connection # time (pre-auth) return tuple(to_bytes(c) for c in self._imap.capabilities) def _do_capabilites(self): raw_response = self._command_and_check("capability", unpack=True) return self._normalise_capabilites(raw_response) def _normalise_capabilites(self, raw_response): raw_response = to_bytes(raw_response) return tuple(raw_response.upper().split()) def has_capability(self, capability): """Return ``True`` if the IMAP server has the given *capability*.""" # FIXME: this will not detect capabilities that are backwards # compatible with the current level. For instance the SORT # capabilities may in the future be named SORT2 which is # still compatible with the current standard and will not # be detected by this method. return to_bytes(capability).upper() in self.capabilities() @require_capability("NAMESPACE") def namespace(self): """Return the namespace for the account as a (personal, other, shared) tuple. Each element may be None if no namespace of that type exists, or a sequence of (prefix, separator) pairs. For convenience the tuple elements may be accessed positionally or using attributes named *personal*, *other* and *shared*. See :rfc:`2342` for more details. """ data = self._command_and_check("namespace") parts = [] for item in parse_response(data): if item is None: parts.append(item) else: converted = [] for prefix, separator in item: if self.folder_encode: prefix = decode_utf7(prefix) converted.append((prefix, to_unicode(separator))) parts.append(tuple(converted)) return Namespace(*parts) def list_folders(self, directory="", pattern="*"): """Get a listing of folders on the server as a list of ``(flags, delimiter, name)`` tuples. Specifying *directory* will limit returned folders to the given base directory. The directory and any child directories will returned. Specifying *pattern* will limit returned folders to those with matching names. The wildcards are supported in *pattern*. ``*`` matches zero or more of any character and ``%`` matches 0 or more characters except the folder delimiter. Calling list_folders with no arguments will recursively list all folders available for the logged in user. Folder names are always returned as unicode strings, and decoded from modified UTF-7, except if folder_decode is not set. """ return self._do_list("LIST", directory, pattern) @require_capability("XLIST") def xlist_folders(self, directory="", pattern="*"): """Execute the XLIST command, returning ``(flags, delimiter, name)`` tuples. This method returns special flags for each folder and a localized name for certain folders (e.g. the name of the inbox may be localized and the flags can be used to determine the actual inbox, even if the name has been localized. A ``XLIST`` response could look something like:: [((b'\\HasNoChildren', b'\\Inbox'), b'/', u'Inbox'), ((b'\\Noselect', b'\\HasChildren'), b'/', u'[Gmail]'), ((b'\\HasNoChildren', b'\\AllMail'), b'/', u'[Gmail]/All Mail'), ((b'\\HasNoChildren', b'\\Drafts'), b'/', u'[Gmail]/Drafts'), ((b'\\HasNoChildren', b'\\Important'), b'/', u'[Gmail]/Important'), ((b'\\HasNoChildren', b'\\Sent'), b'/', u'[Gmail]/Sent Mail'), ((b'\\HasNoChildren', b'\\Spam'), b'/', u'[Gmail]/Spam'), ((b'\\HasNoChildren', b'\\Starred'), b'/', u'[Gmail]/Starred'), ((b'\\HasNoChildren', b'\\Trash'), b'/', u'[Gmail]/Trash')] This is a *deprecated* Gmail-specific IMAP extension (See https://developers.google.com/gmail/imap_extensions#xlist_is_deprecated for more information). The *directory* and *pattern* arguments are as per list_folders(). """ return self._do_list("XLIST", directory, pattern) def list_sub_folders(self, directory="", pattern="*"): """Return a list of subscribed folders on the server as ``(flags, delimiter, name)`` tuples. The default behaviour will list all subscribed folders. The *directory* and *pattern* arguments are as per list_folders(). """ return self._do_list("LSUB", directory, pattern) def _do_list(self, cmd, directory, pattern): directory = self._normalise_folder(directory) pattern = self._normalise_folder(pattern) typ, dat = self._imap._simple_command(cmd, directory, pattern) self._checkok(cmd, typ, dat) typ, dat = self._imap._untagged_response(typ, dat, cmd) return self._proc_folder_list(dat) def _proc_folder_list(self, folder_data): # Filter out empty strings and None's. # This also deals with the special case of - no 'untagged' # responses (ie, no folders). This comes back as [None]. folder_data = [item for item in folder_data if item not in (b"", None)] ret = [] parsed = parse_response(folder_data) for flags, delim, name in chunk(parsed, size=3): if isinstance(name, int): # Some IMAP implementations return integer folder names # with quotes. These get parsed to ints so convert them # back to strings. name = str(name) elif self.folder_encode: name = decode_utf7(name) ret.append((flags, delim, name)) return ret def find_special_folder(self, folder_flag): """Try to locate a special folder, like the Sent or Trash folder. >>> server.find_special_folder(imapclient.SENT) 'INBOX.Sent' This function tries its best to find the correct folder (if any) but uses heuristics when the server is unable to precisely tell where special folders are located. Returns the name of the folder if found, or None otherwise. """ # Detect folder by looking for known attributes # TODO: avoid listing all folders by using extended LIST (RFC6154) for folder in self.list_folders(): if folder and len(folder[0]) > 0 and folder_flag in folder[0]: return folder[2] # Detect folder by looking for common names # We only look for folders in the "personal" namespace of the user if self.has_capability("NAMESPACE"): personal_namespaces = self.namespace().personal else: personal_namespaces = _POPULAR_PERSONAL_NAMESPACES for personal_namespace in personal_namespaces: for pattern in _POPULAR_SPECIAL_FOLDERS.get(folder_flag, tuple()): pattern = personal_namespace[0] + pattern sent_folders = self.list_folders(pattern=pattern) if sent_folders: return sent_folders[0][2] return None def select_folder(self, folder, readonly=False): """Set the current folder on the server. Future calls to methods such as search and fetch will act on the selected folder. Returns a dictionary containing the ``SELECT`` response. At least the ``b'EXISTS'``, ``b'FLAGS'`` and ``b'RECENT'`` keys are guaranteed to exist. An example:: {b'EXISTS': 3, b'FLAGS': (b'\\Answered', b'\\Flagged', b'\\Deleted', ... ), b'RECENT': 0, b'PERMANENTFLAGS': (b'\\Answered', b'\\Flagged', b'\\Deleted', ... ), b'READ-WRITE': True, b'UIDNEXT': 11, b'UIDVALIDITY': 1239278212} """ self._command_and_check("select", self._normalise_folder(folder), readonly) return self._process_select_response(self._imap.untagged_responses) @require_capability("UNSELECT") def unselect_folder(self): r"""Unselect the current folder and release associated resources. Unlike ``close_folder``, the ``UNSELECT`` command does not expunge the mailbox, keeping messages with \Deleted flag set for example. Returns the UNSELECT response string returned by the server. """ logger.debug("< UNSELECT") # IMAP4 class has no `unselect` method so we can't use `_command_and_check` there _typ, data = self._imap._simple_command("UNSELECT") return data[0] def _process_select_response(self, resp): untagged = _dict_bytes_normaliser(resp) out = {} # imaplib doesn't parse these correctly (broken regex) so replace # with the raw values out of the OK section for line in untagged.get("OK", []): match = _RE_SELECT_RESPONSE.match(line) if match: key = match.group("key") if key == b"PERMANENTFLAGS": out[key] = tuple(match.group("data").split()) for key, value in untagged.items(): key = key.upper() if key in (b"OK", b"PERMANENTFLAGS"): continue # already handled above if key in ( b"EXISTS", b"RECENT", b"UIDNEXT", b"UIDVALIDITY", b"HIGHESTMODSEQ", ): value = int(value[0]) elif key == b"READ-WRITE": value = True elif key == b"FLAGS": value = tuple(value[0][1:-1].split()) out[key] = value return out def noop(self): """Execute the NOOP command. This command returns immediately, returning any server side status updates. It can also be used to reset any auto-logout timers. The return value is the server command response message followed by a list of status responses. For example:: (b'NOOP completed.', [(4, b'EXISTS'), (3, b'FETCH', (b'FLAGS', (b'bar', b'sne'))), (6, b'FETCH', (b'FLAGS', (b'sne',)))]) """ tag = self._imap._command("NOOP") return self._consume_until_tagged_response(tag, "NOOP") @require_capability("IDLE") def idle(self): """Put the server into IDLE mode. In this mode the server will return unsolicited responses about changes to the selected mailbox. This method returns immediately. Use ``idle_check()`` to look for IDLE responses and ``idle_done()`` to stop IDLE mode. .. note:: Any other commands issued while the server is in IDLE mode will fail. See :rfc:`2177` for more information about the IDLE extension. """ self._idle_tag = self._imap._command("IDLE") resp = self._imap._get_response() if resp is not None: raise exceptions.IMAPClientError("Unexpected IDLE response: %s" % resp) def _poll_socket(self, sock, timeout=None): """ Polls the socket for events telling us it's available to read. This implementation is more scalable because it ALLOWS your process to have more than 1024 file descriptors. """ poller = select.poll() poller.register(sock.fileno(), select.POLLIN) timeout = timeout * 1000 if timeout is not None else None return poller.poll(timeout) def _select_poll_socket(self, sock, timeout=None): """ Polls the socket for events telling us it's available to read. This implementation is a fallback because it FAILS if your process has more than 1024 file descriptors. We still need this for Windows and some other niche systems. """ return select.select([sock], [], [], timeout)[0] @require_capability("IDLE") def idle_check(self, timeout=None): """Check for any IDLE responses sent by the server. This method should only be called if the server is in IDLE mode (see ``idle()``). By default, this method will block until an IDLE response is received. If *timeout* is provided, the call will block for at most this many seconds while waiting for an IDLE response. The return value is a list of received IDLE responses. These will be parsed with values converted to appropriate types. For example:: [(b'OK', b'Still here'), (1, b'EXISTS'), (1, b'FETCH', (b'FLAGS', (b'\\NotJunk',)))] """ sock = self.socket() # make the socket non-blocking so the timeout can be # implemented for this call sock.settimeout(None) sock.setblocking(0) if POLL_SUPPORT: poll_func = self._poll_socket else: poll_func = self._select_poll_socket try: resps = [] events = poll_func(sock, timeout) if events: while True: try: line = self._imap._get_line() except (socket.timeout, socket.error): break except IMAPClient.AbortError: # An imaplib.IMAP4.abort with "EOF" is raised # under Python 3 err = sys.exc_info()[1] if "EOF" in err.args[0]: break raise else: resps.append(_parse_untagged_response(line)) return resps finally: sock.setblocking(1) self._set_read_timeout() @require_capability("IDLE") def idle_done(self): """Take the server out of IDLE mode. This method should only be called if the server is already in IDLE mode. The return value is of the form ``(command_text, idle_responses)`` where *command_text* is the text sent by the server when the IDLE command finished (eg. ``b'Idle terminated'``) and *idle_responses* is a list of parsed idle responses received since the last call to ``idle_check()`` (if any). These are returned in parsed form as per ``idle_check()``. """ logger.debug("< DONE") self._imap.send(b"DONE\r\n") return self._consume_until_tagged_response(self._idle_tag, "IDLE") def folder_status(self, folder, what=None): """Return the status of *folder*. *what* should be a sequence of status items to query. This defaults to ``('MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN')``. Returns a dictionary of the status items for the folder with keys matching *what*. """ if what is None: what = ("MESSAGES", "RECENT", "UIDNEXT", "UIDVALIDITY", "UNSEEN") else: what = normalise_text_list(what) what_ = "(%s)" % (" ".join(what)) fname = self._normalise_folder(folder) data = self._command_and_check("status", fname, what_) response = parse_response(data) status_items = response[-1] return dict(as_pairs(status_items)) def close_folder(self): """Close the currently selected folder, returning the server response string. """ return self._command_and_check("close", unpack=True) def create_folder(self, folder): """Create *folder* on the server returning the server response string.""" return self._command_and_check( "create", self._normalise_folder(folder), unpack=True ) def rename_folder(self, old_name, new_name): """Change the name of a folder on the server.""" return self._command_and_check( "rename", self._normalise_folder(old_name), self._normalise_folder(new_name), unpack=True, ) def delete_folder(self, folder): """Delete *folder* on the server returning the server response string.""" return self._command_and_check( "delete", self._normalise_folder(folder), unpack=True ) def folder_exists(self, folder): """Return ``True`` if *folder* exists on the server.""" return len(self.list_folders("", folder)) > 0 def subscribe_folder(self, folder): """Subscribe to *folder*, returning the server response string.""" return self._command_and_check("subscribe", self._normalise_folder(folder)) def unsubscribe_folder(self, folder): """Unsubscribe to *folder*, returning the server response string.""" return self._command_and_check("unsubscribe", self._normalise_folder(folder)) def search(self, criteria="ALL", charset=None): """Return a list of messages ids from the currently selected folder matching *criteria*. *criteria* should be a sequence of one or more criteria items. Each criteria item may be either unicode or bytes. Example values:: [u'UNSEEN'] [u'SMALLER', 500] [b'NOT', b'DELETED'] [u'TEXT', u'foo bar', u'FLAGGED', u'SUBJECT', u'baz'] [u'SINCE', date(2005, 4, 3)] IMAPClient will perform conversion and quoting as required. The caller shouldn't do this. It is also possible (but not recommended) to pass the combined criteria as a single string. In this case IMAPClient won't perform quoting, allowing lower-level specification of criteria. Examples of this style:: u'UNSEEN' u'SMALLER 500' b'NOT DELETED' u'TEXT "foo bar" FLAGGED SUBJECT "baz"' b'SINCE 03-Apr-2005' To support complex search expressions, criteria lists can be nested. IMAPClient will insert parentheses in the right places. The following will match messages that are both not flagged and do not have "foo" in the subject:: ['NOT', ['SUBJECT', 'foo', 'FLAGGED']] *charset* specifies the character set of the criteria. It defaults to US-ASCII as this is the only charset that a server is required to support by the RFC. UTF-8 is commonly supported however. Any criteria specified using unicode will be encoded as per *charset*. Specifying a unicode criteria that can not be encoded using *charset* will result in an error. Any criteria specified using bytes will be sent as-is but should use an encoding that matches *charset* (the character set given is still passed on to the server). See :rfc:`3501#section-6.4.4` for more details. Note that criteria arguments that are 8-bit will be transparently sent by IMAPClient as IMAP literals to ensure adherence to IMAP standards. The returned list of message ids will have a special *modseq* attribute. This is set if the server included a MODSEQ value to the search response (i.e. if a MODSEQ criteria was included in the search). """ return self._search(criteria, charset) @require_capability("X-GM-EXT-1") def gmail_search(self, query, charset="UTF-8"): """Search using Gmail's X-GM-RAW attribute. *query* should be a valid Gmail search query string. For example: ``has:attachment in:unread``. The search string may be unicode and will be encoded using the specified *charset* (defaulting to UTF-8). This method only works for IMAP servers that support X-GM-RAW, which is only likely to be Gmail. See https://developers.google.com/gmail/imap_extensions#extension_of_the_search_command_x-gm-raw for more info. """ return self._search([b"X-GM-RAW", query], charset) def _search(self, criteria, charset): args = [] if charset: args.extend([b"CHARSET", to_bytes(charset)]) args.extend(_normalise_search_criteria(criteria, charset)) try: data = self._raw_command_untagged(b"SEARCH", args) except imaplib.IMAP4.error as e: # Make BAD IMAP responses easier to understand to the user, with a link to the docs m = re.match(r"SEARCH command error: BAD \[(.+)\]", str(e)) if m: raise exceptions.InvalidCriteriaError( "{original_msg}\n\n" "This error may have been caused by a syntax error in the criteria: " "{criteria}\nPlease refer to the documentation for more information " "about search criteria syntax..\n" "https://imapclient.readthedocs.io/en/master/#imapclient.IMAPClient.search".format( original_msg=m.group(1), criteria='"%s"' % criteria if not isinstance(criteria, list) else criteria, ) ) # If the exception is not from a BAD IMAP response, re-raise as-is raise return parse_message_list(data) @require_capability("SORT") def sort(self, sort_criteria, criteria="ALL", charset="UTF-8"): """Return a list of message ids from the currently selected folder, sorted by *sort_criteria* and optionally filtered by *criteria*. *sort_criteria* may be specified as a sequence of strings or a single string. IMAPClient will take care any required conversions. Valid *sort_criteria* values:: ['ARRIVAL'] ['SUBJECT', 'ARRIVAL'] 'ARRIVAL' 'REVERSE SIZE' The *criteria* and *charset* arguments are as per :py:meth:`.search`. See :rfc:`5256` for full details. Note that SORT is an extension to the IMAP4 standard so it may not be supported by all IMAP servers. """ args = [ _normalise_sort_criteria(sort_criteria), to_bytes(charset), ] args.extend(_normalise_search_criteria(criteria, charset)) ids = self._raw_command_untagged(b"SORT", args, unpack=True) return [int(i) for i in ids.split()] def thread(self, algorithm="REFERENCES", criteria="ALL", charset="UTF-8"): """Return a list of messages threads from the currently selected folder which match *criteria*. Each returned thread is a list of messages ids. An example return value containing three message threads:: ((1, 2), (3,), (4, 5, 6)) The optional *algorithm* argument specifies the threading algorithm to use. The *criteria* and *charset* arguments are as per :py:meth:`.search`. See :rfc:`5256` for more details. """ algorithm = to_bytes(algorithm) if not self.has_capability(b"THREAD=" + algorithm): raise exceptions.CapabilityError( "The server does not support %s threading algorithm" % algorithm ) args = [algorithm, to_bytes(charset)] + _normalise_search_criteria( criteria, charset ) data = self._raw_command_untagged(b"THREAD", args) return parse_response(data) def get_flags(self, messages): """Return the flags set for each message in *messages* from the currently selected folder. The return value is a dictionary structured like this: ``{ msgid1: (flag1, flag2, ... ), }``. """ response = self.fetch(messages, ["FLAGS"]) return self._filter_fetch_dict(response, b"FLAGS") def add_flags(self, messages, flags, silent=False): """Add *flags* to *messages* in the currently selected folder. *flags* should be a sequence of strings. Returns the flags set for each modified message (see *get_flags*), or None if *silent* is true. """ return self._store(b"+FLAGS", messages, flags, b"FLAGS", silent=silent) def remove_flags(self, messages, flags, silent=False): """Remove one or more *flags* from *messages* in the currently selected folder. *flags* should be a sequence of strings. Returns the flags set for each modified message (see *get_flags*), or None if *silent* is true. """ return self._store(b"-FLAGS", messages, flags, b"FLAGS", silent=silent) def set_flags(self, messages, flags, silent=False): """Set the *flags* for *messages* in the currently selected folder. *flags* should be a sequence of strings. Returns the flags set for each modified message (see *get_flags*), or None if *silent* is true. """ return self._store(b"FLAGS", messages, flags, b"FLAGS", silent=silent) def get_gmail_labels(self, messages): """Return the label set for each message in *messages* in the currently selected folder. The return value is a dictionary structured like this: ``{ msgid1: (label1, label2, ... ), }``. This only works with IMAP servers that support the X-GM-LABELS attribute (eg. Gmail). """ response = self.fetch(messages, [b"X-GM-LABELS"]) response = self._filter_fetch_dict(response, b"X-GM-LABELS") return {msg: utf7_decode_sequence(labels) for msg, labels in response.items()} def add_gmail_labels(self, messages, labels, silent=False): """Add *labels* to *messages* in the currently selected folder. *labels* should be a sequence of strings. Returns the label set for each modified message (see *get_gmail_labels*), or None if *silent* is true. This only works with IMAP servers that support the X-GM-LABELS attribute (eg. Gmail). """ return self._gm_label_store(b"+X-GM-LABELS", messages, labels, silent=silent) def remove_gmail_labels(self, messages, labels, silent=False): """Remove one or more *labels* from *messages* in the currently selected folder, or None if *silent* is true. *labels* should be a sequence of strings. Returns the label set for each modified message (see *get_gmail_labels*). This only works with IMAP servers that support the X-GM-LABELS attribute (eg. Gmail). """ return self._gm_label_store(b"-X-GM-LABELS", messages, labels, silent=silent) def set_gmail_labels(self, messages, labels, silent=False): """Set the *labels* for *messages* in the currently selected folder. *labels* should be a sequence of strings. Returns the label set for each modified message (see *get_gmail_labels*), or None if *silent* is true. This only works with IMAP servers that support the X-GM-LABELS attribute (eg. Gmail). """ return self._gm_label_store(b"X-GM-LABELS", messages, labels, silent=silent) def delete_messages(self, messages, silent=False): """Delete one or more *messages* from the currently selected folder. Returns the flags set for each modified message (see *get_flags*). """ return self.add_flags(messages, DELETED, silent=silent) def fetch(self, messages, data, modifiers=None): """Retrieve selected *data* associated with one or more *messages* in the currently selected folder. *data* should be specified as a sequence of strings, one item per data selector, for example ``['INTERNALDATE', 'RFC822']``. *modifiers* are required for some extensions to the IMAP protocol (eg. :rfc:`4551`). These should be a sequence of strings if specified, for example ``['CHANGEDSINCE 123']``. A dictionary is returned, indexed by message number. Each item in this dictionary is also a dictionary, with an entry corresponding to each item in *data*. Returned values will be appropriately typed. For example, integer values will be returned as Python integers, timestamps will be returned as datetime instances and ENVELOPE responses will be returned as :py:class:`Envelope ` instances. String data will generally be returned as bytes (Python 3) or str (Python 2). In addition to an element for each *data* item, the dict returned for each message also contains a *SEQ* key containing the sequence number for the message. This allows for mapping between the UID and sequence number (when the *use_uid* property is ``True``). Example:: >> c.fetch([3293, 3230], ['INTERNALDATE', 'FLAGS']) {3230: {b'FLAGS': (b'\\Seen',), b'INTERNALDATE': datetime.datetime(2011, 1, 30, 13, 32, 9), b'SEQ': 84}, 3293: {b'FLAGS': (), b'INTERNALDATE': datetime.datetime(2011, 2, 24, 19, 30, 36), b'SEQ': 110}} """ if not messages: return {} args = [ "FETCH", join_message_ids(messages), seq_to_parenstr_upper(data), seq_to_parenstr_upper(modifiers) if modifiers else None, ] if self.use_uid: args.insert(0, "UID") tag = self._imap._command(*args) typ, data = self._imap._command_complete("FETCH", tag) self._checkok("fetch", typ, data) typ, data = self._imap._untagged_response(typ, data, "FETCH") return parse_fetch_response(data, self.normalise_times, self.use_uid) def append(self, folder, msg, flags=(), msg_time=None): """Append a message to *folder*. *msg* should be a string contains the full message including headers. *flags* should be a sequence of message flags to set. If not specified no flags will be set. *msg_time* is an optional datetime instance specifying the date and time to set on the message. The server will set a time if it isn't specified. If *msg_time* contains timezone information (tzinfo), this will be honoured. Otherwise the local machine's time zone sent to the server. Returns the APPEND response as returned by the server. """ if msg_time: time_val = '"%s"' % datetime_to_INTERNALDATE(msg_time) time_val = to_unicode(time_val) else: time_val = None return self._command_and_check( "append", self._normalise_folder(folder), seq_to_parenstr(flags), time_val, to_bytes(msg), unpack=True, ) @require_capability("MULTIAPPEND") def multiappend(self, folder, msgs): """Append messages to *folder* using the MULTIAPPEND feature from :rfc:`3502`. *msgs* must be an iterable. Each item must be either a string containing the full message including headers, or a dict containing the keys "msg" with the full message as before, "flags" with a sequence of message flags to set, and "date" with a datetime instance specifying the internal date to set. The keys "flags" and "date" are optional. Returns the APPEND response from the server. """ def chunks(): for m in msgs: if isinstance(m, dict): if "flags" in m: yield to_bytes(seq_to_parenstr(m["flags"])) if "date" in m: yield to_bytes('"%s"' % datetime_to_INTERNALDATE(m["date"])) yield _literal(to_bytes(m["msg"])) else: yield _literal(to_bytes(m)) msgs = list(chunks()) return self._raw_command( b"APPEND", [self._normalise_folder(folder)] + msgs, uid=False, ) def copy(self, messages, folder): """Copy one or more messages from the current folder to *folder*. Returns the COPY response string returned by the server. """ return self._command_and_check( "copy", join_message_ids(messages), self._normalise_folder(folder), uid=True, unpack=True, ) @require_capability("MOVE") def move(self, messages, folder): """Atomically move messages to another folder. Requires the MOVE capability, see :rfc:`6851`. :param messages: List of message UIDs to move. :param folder: The destination folder name. """ return self._command_and_check( "move", join_message_ids(messages), self._normalise_folder(folder), uid=True, unpack=True, ) def expunge(self, messages=None): """Use of the *messages* argument is discouraged. Please see the ``uid_expunge`` method instead. When, no *messages* are specified, remove all messages from the currently selected folder that have the ``\\Deleted`` flag set. The return value is the server response message followed by a list of expunge responses. For example:: ('Expunge completed.', [(2, 'EXPUNGE'), (1, 'EXPUNGE'), (0, 'RECENT')]) In this case, the responses indicate that the message with sequence numbers 2 and 1 where deleted, leaving no recent messages in the folder. See :rfc:`3501#section-6.4.3` section 6.4.3 and :rfc:`3501#section-7.4.1` section 7.4.1 for more details. When *messages* are specified, remove the specified messages from the selected folder, provided those messages also have the ``\\Deleted`` flag set. The return value is ``None`` in this case. Expunging messages by id(s) requires that *use_uid* is ``True`` for the client. See :rfc:`4315#section-2.1` section 2.1 for more details. """ if messages: if not self.use_uid: raise ValueError("cannot EXPUNGE by ID when not using uids") return self._command_and_check( "EXPUNGE", join_message_ids(messages), uid=True ) tag = self._imap._command("EXPUNGE") return self._consume_until_tagged_response(tag, "EXPUNGE") @require_capability("UIDPLUS") def uid_expunge(self, messages): """Expunge deleted messages with the specified message ids from the folder. This requires the UIDPLUS capability. See :rfc:`4315#section-2.1` section 2.1 for more details. """ return self._command_and_check("EXPUNGE", join_message_ids(messages), uid=True) @require_capability("ACL") def getacl(self, folder): """Returns a list of ``(who, acl)`` tuples describing the access controls for *folder*. """ data = self._command_and_check("getacl", self._normalise_folder(folder)) parts = list(response_lexer.TokenSource(data)) parts = parts[1:] # First item is folder name return [(parts[i], parts[i + 1]) for i in range(0, len(parts), 2)] @require_capability("ACL") def setacl(self, folder, who, what): """Set an ACL (*what*) for user (*who*) for a folder. Set *what* to an empty string to remove an ACL. Returns the server response string. """ return self._command_and_check( "setacl", self._normalise_folder(folder), who, what, unpack=True ) @require_capability("QUOTA") def get_quota(self, mailbox="INBOX"): """Get the quotas associated with a mailbox. Returns a list of Quota objects. """ return self.get_quota_root(mailbox)[1] @require_capability("QUOTA") def _get_quota(self, quota_root=""): """Get the quotas associated with a quota root. This method is not private but put behind an underscore to show that it is a low-level function. Users probably want to use `get_quota` instead. Returns a list of Quota objects. """ return _parse_quota(self._command_and_check("getquota", _quote(quota_root))) @require_capability("QUOTA") def get_quota_root(self, mailbox): """Get the quota roots for a mailbox. The IMAP server responds with the quota root and the quotas associated so there is usually no need to call `get_quota` after. See :rfc:`2087` for more details. Return a tuple of MailboxQuotaRoots and list of Quota associated """ quota_root_rep = self._raw_command_untagged( b"GETQUOTAROOT", to_bytes(mailbox), uid=False, response_name="QUOTAROOT" ) quota_rep = self._imap.untagged_responses.pop("QUOTA", []) quota_root_rep = parse_response(quota_root_rep) quota_root = MailboxQuotaRoots( to_unicode(quota_root_rep[0]), [to_unicode(q) for q in quota_root_rep[1:]] ) return quota_root, _parse_quota(quota_rep) @require_capability("QUOTA") def set_quota(self, quotas): """Set one or more quotas on resources. :param quotas: list of Quota objects """ if not quotas: return quota_root = None set_quota_args = [] for quota in quotas: if quota_root is None: quota_root = quota.quota_root elif quota_root != quota.quota_root: raise ValueError("set_quota only accepts a single quota root") set_quota_args.append("{} {}".format(quota.resource, quota.limit)) set_quota_args = " ".join(set_quota_args) args = [to_bytes(_quote(quota_root)), to_bytes("({})".format(set_quota_args))] response = self._raw_command_untagged( b"SETQUOTA", args, uid=False, response_name="QUOTA" ) return _parse_quota(response) def _check_resp(self, expected, command, typ, data): """Check command responses for errors. Raises IMAPClient.Error if the command fails. """ if typ != expected: raise exceptions.IMAPClientError( "%s failed: %s" % (command, to_unicode(data[0])) ) def _consume_until_tagged_response(self, tag, command): tagged_commands = self._imap.tagged_commands resps = [] while True: line = self._imap._get_response() if tagged_commands[tag]: break resps.append(_parse_untagged_response(line)) typ, data = tagged_commands.pop(tag) self._checkok(command, typ, data) return data[0], resps def _raw_command_untagged( self, command, args, response_name=None, unpack=False, uid=True ): # TODO: eventually this should replace _command_and_check (call it _command) typ, data = self._raw_command(command, args, uid=uid) if response_name is None: response_name = command typ, data = self._imap._untagged_response(typ, data, to_unicode(response_name)) self._checkok(to_unicode(command), typ, data) if unpack: return data[0] return data def _raw_command(self, command, args, uid=True): """Run the specific command with the arguments given. 8-bit arguments are sent as literals. The return value is (typ, data). This sidesteps much of imaplib's command sending infrastructure because imaplib can't send more than one literal. *command* should be specified as bytes. *args* should be specified as a list of bytes. """ command = command.upper() if isinstance(args, tuple): args = list(args) if not isinstance(args, list): args = [args] tag = self._imap._new_tag() prefix = [to_bytes(tag)] if uid and self.use_uid: prefix.append(b"UID") prefix.append(command) line = [] for item, is_last in _iter_with_last(prefix + args): if not isinstance(item, bytes): raise ValueError("command args must be passed as bytes") if _is8bit(item): # If a line was already started send it if line: out = b" ".join(line) logger.debug("> %s", out) self._imap.send(out) line = [] # Now send the (unquoted) literal if isinstance(item, _quoted): item = item.original self._send_literal(tag, item) if not is_last: self._imap.send(b" ") else: line.append(item) if line: out = b" ".join(line) logger.debug("> %s", out) self._imap.send(out) self._imap.send(b"\r\n") return self._imap._command_complete(to_unicode(command), tag) def _send_literal(self, tag, item): """Send a single literal for the command with *tag*.""" if b"LITERAL+" in self._cached_capabilities: out = b" {" + str(len(item)).encode("ascii") + b"+}\r\n" + item logger.debug("> %s", debug_trunc(out, 64)) self._imap.send(out) return out = b" {" + str(len(item)).encode("ascii") + b"}\r\n" logger.debug("> %s", out) self._imap.send(out) # Wait for continuation response while self._imap._get_response(): tagged_resp = self._imap.tagged_commands.get(tag) if tagged_resp: raise exceptions.IMAPClientAbortError( "unexpected response while waiting for continuation response: " + repr(tagged_resp) ) logger.debug(" (literal) > %s", debug_trunc(item, 256)) self._imap.send(item) def _command_and_check( self, command, *args, unpack: bool = False, uid: bool = False ): if uid and self.use_uid: command = to_unicode(command) # imaplib must die typ, data = self._imap.uid(command, *args) else: meth = getattr(self._imap, to_unicode(command)) typ, data = meth(*args) self._checkok(command, typ, data) if unpack: return data[0] return data def _checkok(self, command, typ, data): self._check_resp("OK", command, typ, data) def _gm_label_store(self, cmd, messages, labels, silent): response = self._store( cmd, messages, self._normalise_labels(labels), b"X-GM-LABELS", silent=silent ) return ( {msg: utf7_decode_sequence(labels) for msg, labels in response.items()} if response else None ) def _store(self, cmd, messages, flags, fetch_key, silent): """Worker function for the various flag manipulation methods. *cmd* is the STORE command to use (eg. '+FLAGS'). """ if not messages: return {} if silent: cmd += b".SILENT" data = self._command_and_check( "store", join_message_ids(messages), cmd, seq_to_parenstr(flags), uid=True ) if silent: return None return self._filter_fetch_dict(parse_fetch_response(data), fetch_key) def _filter_fetch_dict(self, fetch_dict, key): return dict((msgid, data[key]) for msgid, data in fetch_dict.items()) def _normalise_folder(self, folder_name): if isinstance(folder_name, bytes): folder_name = folder_name.decode("ascii") if self.folder_encode: folder_name = encode_utf7(folder_name) return _quote(folder_name) def _normalise_labels(self, labels): if isinstance(labels, (str, bytes)): labels = (labels,) return [_quote(encode_utf7(label)) for label in labels] @property def welcome(self): """access the server greeting message""" try: return self._imap.welcome except AttributeError: pass def _quote(arg): if isinstance(arg, str): arg = arg.replace("\\", "\\\\") arg = arg.replace('"', '\\"') q = '"' else: arg = arg.replace(b"\\", b"\\\\") arg = arg.replace(b'"', b'\\"') q = b'"' return q + arg + q def _normalise_search_criteria(criteria, charset=None): if not criteria: raise exceptions.InvalidCriteriaError("no criteria specified") if not charset: charset = "us-ascii" if isinstance(criteria, (str, bytes)): return [to_bytes(criteria, charset)] out = [] for item in criteria: if isinstance(item, int): out.append(str(item).encode("ascii")) elif isinstance(item, (datetime, date)): out.append(format_criteria_date(item)) elif isinstance(item, (list, tuple)): # Process nested criteria list and wrap in parens. inner = _normalise_search_criteria(item) inner[0] = b"(" + inner[0] inner[-1] = inner[-1] + b")" out.extend(inner) # flatten else: out.append(_quoted.maybe(to_bytes(item, charset))) return out def _normalise_sort_criteria(criteria, charset=None): if isinstance(criteria, (str, bytes)): criteria = [criteria] return b"(" + b" ".join(to_bytes(item).upper() for item in criteria) + b")" class _literal(bytes): """Hold message data that should always be sent as a literal.""" class _quoted(bytes): """ This class holds a quoted bytes value which provides access to the unquoted value via the *original* attribute. They should be created via the *maybe* classmethod. """ @classmethod def maybe(cls, original): """Maybe quote a bytes value. If the input requires no quoting it is returned unchanged. If quoting is required a *_quoted* instance is returned. This holds the quoted version of the input while also providing access to the original unquoted source. """ quoted = original.replace(b"\\", b"\\\\") quoted = quoted.replace(b'"', b'\\"') if quoted != original or b" " in quoted or not quoted: out = cls(b'"' + quoted + b'"') out.original = original return out return original # normalise_text_list, seq_to_parentstr etc have to return unicode # because imaplib handles flags and sort criteria assuming these are # passed as unicode def normalise_text_list(items): return list(_normalise_text_list(items)) def seq_to_parenstr(items): return _join_and_paren(_normalise_text_list(items)) def seq_to_parenstr_upper(items): return _join_and_paren(item.upper() for item in _normalise_text_list(items)) def _join_and_paren(items): return "(" + " ".join(items) + ")" def _normalise_text_list(items): if isinstance(items, (str, bytes)): items = (items,) return (to_unicode(c) for c in items) def join_message_ids(messages): """Convert a sequence of messages ids or a single integer message id into an id byte string for use with IMAP commands """ if isinstance(messages, (str, bytes, int)): messages = (to_bytes(messages),) return b",".join(_maybe_int_to_bytes(m) for m in messages) def _maybe_int_to_bytes(val): if isinstance(val, int): return str(val).encode("us-ascii") return to_bytes(val) def _parse_untagged_response(text): assert_imap_protocol(text.startswith(b"* ")) text = text[2:] if text.startswith((b"OK ", b"NO ")): return tuple(text.split(b" ", 1)) return parse_response([text]) def as_pairs(items): i = 0 last_item = None for item in items: if i % 2: yield last_item, item else: last_item = item i += 1 def as_triplets(items): a = iter(items) return zip(a, a, a) def _is8bit(data): return isinstance(data, _literal) or any(b > 127 for b in data) def _iter_with_last(items): last_i = len(items) - 1 for i, item in enumerate(items): yield item, i == last_i _not_present = object() class _dict_bytes_normaliser: """Wrap a dict with unicode/bytes keys and normalise the keys to bytes. """ def __init__(self, d): self._d = d def iteritems(self): for key, value in self._d.items(): yield to_bytes(key), value # For Python 3 compatibility. items = iteritems def __contains__(self, ink): for k in self._gen_keys(ink): if k in self._d: return True return False def get(self, ink, default=_not_present): for k in self._gen_keys(ink): try: return self._d[k] except KeyError: pass if default == _not_present: raise KeyError(ink) return default def pop(self, ink, default=_not_present): for k in self._gen_keys(ink): try: return self._d.pop(k) except KeyError: pass if default == _not_present: raise KeyError(ink) return default def _gen_keys(self, k): yield k if isinstance(k, bytes): yield to_unicode(k) else: yield to_bytes(k) def debug_trunc(v, maxlen): if len(v) < maxlen: return repr(v) hl = maxlen // 2 return repr(v[:hl]) + "..." + repr(v[-hl:]) def utf7_decode_sequence(seq): return [decode_utf7(s) for s in seq] def _parse_quota(quota_rep): quota_rep = parse_response(quota_rep) rv = [] for quota_root, quota_resource_infos in as_pairs(quota_rep): for quota_resource_info in as_triplets(quota_resource_infos): rv.append( Quota( quota_root=to_unicode(quota_root), resource=to_unicode(quota_resource_info[0]), usage=quota_resource_info[1], limit=quota_resource_info[2], ) ) return rv class IMAPlibLoggerAdapter(LoggerAdapter): """Adapter preventing IMAP secrets from going to the logging facility.""" def process(self, msg, kwargs): # msg is usually unicode but see #367. Convert bytes to # unicode if required. if isinstance(msg, bytes): msg = msg.decode("ascii", "ignore") for command in ("LOGIN", "AUTHENTICATE"): if msg.startswith(">") and command in msg: msg_start = msg.split(command)[0] msg = "{}{} **REDACTED**".format(msg_start, command) break return super().process(msg, kwargs) imapclient-3.0.1/imapclient/interact.py000066400000000000000000000077051453256433100201670ustar00rootroot00000000000000#!/usr/bin/python # Copyright (c) 2020, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import argparse from getpass import getpass from . import imapclient from .config import create_client_from_config, get_config_defaults, parse_config_file def command_line() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument( "-H", "--host", dest="host", action="store", help="IMAP host connect to" ) parser.add_argument( "-u", "--username", dest="username", action="store", help="Username to login with", ) parser.add_argument( "-p", "--password", dest="password", action="store", help="Password to login with", ) parser.add_argument( "-P", "--port", dest="port", action="store", type=int, default=None, help="IMAP port to use (default is 993 for TLS, or 143 otherwise)", ) ssl_group = parser.add_mutually_exclusive_group() ssl_group.add_argument( "-s", "--ssl", dest="ssl", action="store_true", default=None, help="Use SSL/TLS connection (default)", ) ssl_group.add_argument( "--insecure", dest="insecure", action="store_true", default=False, help="Use insecure connection (i.e. without SSL/TLS)", ) parser.add_argument( "-f", "--file", dest="file", action="store", default=None, help="Config file (same as livetest)", ) args = parser.parse_args() if args.file: if ( args.host or args.username or args.password or args.port or args.ssl or args.insecure ): parser.error("If -f/--file is given no other options can be used") # Use the options in the config file args = parse_config_file(args.file) return args args.ssl = not args.insecure # Scan through arguments, filling in defaults and prompting when # a compulsory argument wasn't provided. compulsory_args = ("host", "username", "password") for name, default_value in get_config_defaults().items(): value = getattr(args, name, default_value) if name in compulsory_args and value is None: value = getpass(name + ": ") setattr(args, name, value) return args def main() -> int: args = command_line() print("Connecting...") client = create_client_from_config(args) print("Connected.") banner = '\nIMAPClient instance is "c"' def ptpython(c: imapclient.IMAPClient) -> None: from ptpython.repl import embed # type: ignore[import-not-found] embed(globals(), locals()) def ipython_400(c: imapclient.IMAPClient) -> None: from IPython.terminal.embed import ( # type: ignore[import-not-found] InteractiveShellEmbed, ) ipshell = InteractiveShellEmbed(banner1=banner) ipshell("") def ipython_011(c: imapclient.IMAPClient) -> None: from IPython.frontend.terminal.embed import ( # type: ignore[import-not-found] InteractiveShellEmbed, ) ipshell = InteractiveShellEmbed(banner1=banner) ipshell("") def ipython_010(c: imapclient.IMAPClient) -> None: from IPython.Shell import IPShellEmbed # type: ignore[import-not-found] IPShellEmbed("", banner=banner)() def builtin(c: imapclient.IMAPClient) -> None: import code code.interact(banner, local={"c": c}) shell_attempts = ( ptpython, ipython_400, ipython_011, ipython_010, builtin, ) for shell in shell_attempts: try: shell(client) except ImportError: pass else: break return 0 if __name__ == "__main__": main() imapclient-3.0.1/imapclient/response_lexer.py000066400000000000000000000133251453256433100214060ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses """ A lexical analyzer class for IMAP responses. Although Lexer does all the work, TokenSource is the class to use for external callers. """ from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING, Union from .util import assert_imap_protocol __all__ = ["TokenSource"] CTRL_CHARS = frozenset(c for c in range(32)) ALL_CHARS = frozenset(c for c in range(256)) SPECIALS = frozenset(c for c in b' ()%"[') NON_SPECIALS = ALL_CHARS - SPECIALS - CTRL_CHARS WHITESPACE = frozenset(c for c in b" \t\r\n") BACKSLASH = ord("\\") OPEN_SQUARE = ord("[") CLOSE_SQUARE = ord("]") DOUBLE_QUOTE = ord('"') class TokenSource: """ A simple iterator for the Lexer class that also provides access to the current IMAP literal. """ def __init__(self, text: List[bytes]): self.lex = Lexer(text) self.src = iter(self.lex) @property def current_literal(self) -> Optional[bytes]: if TYPE_CHECKING: assert self.lex.current_source is not None return self.lex.current_source.literal def __iter__(self) -> Iterator[bytes]: return self.src class Lexer: """ A lexical analyzer class for IMAP """ def __init__(self, text: List[bytes]): self.sources = (LiteralHandlingIter(chunk) for chunk in text) self.current_source: Optional[LiteralHandlingIter] = None def read_until( self, stream_i: "PushableIterator", end_char: int, escape: bool = True ) -> bytearray: token = bytearray() try: for nextchar in stream_i: if escape and nextchar == BACKSLASH: escaper = nextchar nextchar = next(stream_i) if nextchar not in (escaper, end_char): token.append(escaper) # Don't touch invalid escaping elif nextchar == end_char: break token.append(nextchar) else: raise ValueError("No closing '%s'" % chr(end_char)) except StopIteration: raise ValueError("No closing '%s'" % chr(end_char)) token.append(end_char) return token def read_token_stream(self, stream_i: "PushableIterator") -> Iterator[bytearray]: whitespace = WHITESPACE wordchars = NON_SPECIALS read_until = self.read_until while True: # Whitespace for nextchar in stream_i: if nextchar not in whitespace: stream_i.push(nextchar) break # done skipping over the whitespace # Non-whitespace token = bytearray() for nextchar in stream_i: if nextchar in wordchars: token.append(nextchar) elif nextchar == OPEN_SQUARE: token.append(nextchar) token.extend(read_until(stream_i, CLOSE_SQUARE, escape=False)) else: if nextchar in whitespace: yield token elif nextchar == DOUBLE_QUOTE: assert_imap_protocol(not token) token.append(nextchar) token.extend(read_until(stream_i, nextchar)) yield token else: # Other punctuation, eg. "(". This ends the current token. if token: yield token yield bytearray([nextchar]) break else: if token: yield token break def __iter__(self) -> Iterator[bytes]: for source in self.sources: self.current_source = source for tok in self.read_token_stream(iter(source)): yield bytes(tok) # imaplib has poor handling of 'literals' - it both fails to remove the # {size} marker, and fails to keep responses grouped into the same logical # 'line'. What we end up with is a list of response 'records', where each # record is either a simple string, or tuple of (str_with_lit, literal) - # where str_with_lit is a string with the {xxx} marker at its end. Note # that each element of this list does *not* correspond 1:1 with the # untagged responses. # (http://bugs.python.org/issue5045 also has comments about this) # So: we have a special object for each of these records. When a # string literal is processed, we peek into this object to grab the # literal. class LiteralHandlingIter: def __init__(self, resp_record: Union[Tuple[bytes, bytes], bytes]): self.literal: Optional[bytes] if isinstance(resp_record, tuple): # A 'record' with a string which includes a literal marker, and # the literal itself. self.src_text = resp_record[0] assert_imap_protocol(self.src_text.endswith(b"}"), self.src_text) self.literal = resp_record[1] else: # just a line with no literals. self.src_text = resp_record self.literal = None def __iter__(self) -> "PushableIterator": return PushableIterator(self.src_text) class PushableIterator: NO_MORE = object() def __init__(self, it: bytes): self.it = iter(it) self.pushed: List[int] = [] def __iter__(self) -> "PushableIterator": return self def __next__(self) -> int: if self.pushed: return self.pushed.pop() return next(self.it) # For Python 2 compatibility next = __next__ def push(self, item: int) -> None: self.pushed.append(item) imapclient-3.0.1/imapclient/response_parser.py000066400000000000000000000215011453256433100215560ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses """ Parsing for IMAP command responses with focus on FETCH responses as returned by imaplib. Initially inspired by http://effbot.org/zone/simple-iterator-parser.htm """ # TODO more exact error reporting import datetime import re import sys from collections import defaultdict from typing import cast, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union from .datetime_util import parse_to_datetime from .exceptions import ProtocolError from .response_lexer import TokenSource from .response_types import Address, BodyData, Envelope, SearchIds from .typing_imapclient import _Atom __all__ = ["parse_response", "parse_message_list"] def parse_response(data: List[bytes]) -> Tuple[_Atom, ...]: """Pull apart IMAP command responses. Returns nested tuples of appropriately typed objects. """ if data == [None]: return tuple() return tuple(gen_parsed_response(data)) _msg_id_pattern = re.compile(r"(\d+(?: +\d+)*)") def parse_message_list(data: List[Union[bytes, str]]) -> SearchIds: """Parse a list of message ids and return them as a list. parse_response is also capable of doing this but this is faster. This also has special handling of the optional MODSEQ part of a SEARCH response. The returned list is a SearchIds instance which has a *modseq* attribute which contains the MODSEQ response (if returned by the server). """ if len(data) != 1: raise ValueError("unexpected message list data") message_data = data[0] if not message_data: return SearchIds() if isinstance(message_data, bytes): message_data = message_data.decode("ascii") m = _msg_id_pattern.match(message_data) if not m: raise ValueError("unexpected message list format") ids = SearchIds(int(n) for n in m.group(1).split()) # Parse any non-numeric part on the end using parse_response (this # is likely to be the MODSEQ section). extra = message_data[m.end(1) :] if extra: for item in parse_response([extra.encode("ascii")]): if ( isinstance(item, tuple) and len(item) == 2 and cast(bytes, item[0]).lower() == b"modseq" ): if TYPE_CHECKING: assert isinstance(item[1], int) ids.modseq = item[1] elif isinstance(item, int): ids.append(item) return ids def gen_parsed_response(text: List[bytes]) -> Iterator[_Atom]: if not text: return src = TokenSource(text) token = None try: for token in src: yield atom(src, token) except ProtocolError: raise except ValueError: _, err, _ = sys.exc_info() raise ProtocolError("%s: %r" % (str(err), token)) _ParseFetchResponseInnerDict = Dict[ bytes, Optional[Union[datetime.datetime, int, BodyData, Envelope, _Atom]] ] def parse_fetch_response( text: List[bytes], normalise_times: bool = True, uid_is_key: bool = True ) -> "defaultdict[int, _ParseFetchResponseInnerDict]": """Pull apart IMAP FETCH responses as returned by imaplib. Returns a dictionary, keyed by message ID. Each value a dictionary keyed by FETCH field type (eg."RFC822"). """ if text == [None]: return defaultdict() response = gen_parsed_response(text) parsed_response: "defaultdict[int, _ParseFetchResponseInnerDict]" = defaultdict( dict ) while True: try: msg_id = seq = _int_or_error(next(response), "invalid message ID") except StopIteration: break try: msg_response = next(response) except StopIteration: raise ProtocolError("unexpected EOF") if not isinstance(msg_response, tuple): raise ProtocolError("bad response type: %s" % repr(msg_response)) if len(msg_response) % 2: raise ProtocolError( "uneven number of response items: %s" % repr(msg_response) ) # always return the sequence of the message, so it is available # even if we return keyed by UID. msg_data: _ParseFetchResponseInnerDict = {b"SEQ": seq} for i in range(0, len(msg_response), 2): msg_attribute = msg_response[i] if TYPE_CHECKING: assert isinstance(msg_attribute, bytes) word = msg_attribute.upper() value = msg_response[i + 1] if word == b"UID": uid = _int_or_error(value, "invalid UID") if uid_is_key: msg_id = uid else: msg_data[word] = uid elif word == b"INTERNALDATE": msg_data[word] = _convert_INTERNALDATE(value, normalise_times) elif word == b"ENVELOPE": msg_data[word] = _convert_ENVELOPE(value, normalise_times) elif word in (b"BODY", b"BODYSTRUCTURE"): if TYPE_CHECKING: assert isinstance(value, tuple) msg_data[word] = BodyData.create(value) else: msg_data[word] = value parsed_response[msg_id].update(msg_data) return parsed_response def _int_or_error(value: _Atom, error_text: str) -> int: try: return int(value) # type: ignore[arg-type] except (TypeError, ValueError): raise ProtocolError("%s: %s" % (error_text, repr(value))) def _convert_INTERNALDATE( date_string: _Atom, normalise_times: bool = True ) -> Optional[datetime.datetime]: if date_string is None: return None try: if TYPE_CHECKING: assert isinstance(date_string, bytes) return parse_to_datetime(date_string, normalise=normalise_times) except ValueError: return None def _convert_ENVELOPE( envelope_response: _Atom, normalise_times: bool = True ) -> Envelope: if TYPE_CHECKING: assert isinstance(envelope_response, tuple) dt = None if envelope_response[0]: try: if TYPE_CHECKING: assert isinstance(envelope_response[0], bytes) dt = parse_to_datetime( envelope_response[0], normalise=normalise_times, ) except ValueError: pass subject = envelope_response[1] in_reply_to = envelope_response[8] message_id = envelope_response[9] if TYPE_CHECKING: assert isinstance(subject, bytes) assert isinstance(in_reply_to, bytes) assert isinstance(message_id, bytes) # addresses contains a tuple of addresses # from, sender, reply_to, to, cc, bcc headers addresses: List[Optional[Tuple[Address, ...]]] = [] for addr_list in envelope_response[2:8]: addrs = [] if addr_list: if TYPE_CHECKING: assert isinstance(addr_list, tuple) for addr_tuple in addr_list: if TYPE_CHECKING: assert isinstance(addr_tuple, tuple) if addr_tuple: if TYPE_CHECKING: addr_tuple = cast(Tuple[bytes, bytes, bytes, bytes], addr_tuple) addrs.append(Address(*addr_tuple)) addresses.append(tuple(addrs)) else: addresses.append(None) return Envelope( date=dt, subject=subject, from_=addresses[0], sender=addresses[1], reply_to=addresses[2], to=addresses[3], cc=addresses[4], bcc=addresses[5], in_reply_to=in_reply_to, message_id=message_id, ) def atom(src: TokenSource, token: bytes) -> _Atom: if token == b"(": return parse_tuple(src) if token == b"NIL": return None if token[:1] == b"{": literal_len = int(token[1:-1]) literal_text = src.current_literal if literal_text is None: raise ProtocolError("No literal corresponds to %r" % token) if len(literal_text) != literal_len: raise ProtocolError( "Expecting literal of size %d, got %d" % (literal_len, len(literal_text)) ) return literal_text if len(token) >= 2 and (token[:1] == token[-1:] == b'"'): return token[1:-1] if token.isdigit() and (token[:1] != b"0" or len(token) == 1): # this prevents converting items like 0123 to 123 return int(token) return token def parse_tuple(src: TokenSource) -> _Atom: out: List[_Atom] = [] for token in src: if token == b")": return tuple(out) out.append(atom(src, token)) # no terminator raise ProtocolError('Tuple incomplete before "(%s"' % _fmt_tuple(out)) def _fmt_tuple(t: List[_Atom]) -> str: return " ".join(str(item) for item in t) imapclient-3.0.1/imapclient/response_types.py000066400000000000000000000117701453256433100214350ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import dataclasses import datetime from email.utils import formataddr from typing import Any, List, Optional, Tuple, TYPE_CHECKING, Union from .typing_imapclient import _Atom from .util import to_unicode @dataclasses.dataclass class Envelope: r"""Represents envelope structures of messages. Returned when parsing ENVELOPE responses. :ivar date: A datetime instance that represents the "Date" header. :ivar subject: A string that contains the "Subject" header. :ivar from\_: A tuple of Address objects that represent one or more addresses from the "From" header, or None if header does not exist. :ivar sender: As for from\_ but represents the "Sender" header. :ivar reply_to: As for from\_ but represents the "Reply-To" header. :ivar to: As for from\_ but represents the "To" header. :ivar cc: As for from\_ but represents the "Cc" header. :ivar bcc: As for from\_ but represents the "Bcc" recipients. :ivar in_reply_to: A string that contains the "In-Reply-To" header. :ivar message_id: A string that contains the "Message-Id" header. A particular issue to watch out for is IMAP's handling of "group syntax" in address fields. This is often encountered as a recipient header of the form:: undisclosed-recipients:; but can also be expressed per this more general example:: A group: a@example.com, B ; This example would yield the following Address tuples:: Address(name=None, route=None, mailbox=u'A group', host=None) Address(name=None, route=None, mailbox=u'a', host=u'example.com') Address(name=u'B', route=None, mailbox=u'b', host=u'example.org') Address(name=None, route=None, mailbox=None, host=None) The first Address, where ``host`` is ``None``, indicates the start of the group. The ``mailbox`` field contains the group name. The final Address, where both ``mailbox`` and ``host`` are ``None``, indicates the end of the group. See :rfc:`3501#section-7.4.2` and :rfc:`2822` for further details. """ date: Optional[datetime.datetime] subject: bytes from_: Optional[Tuple["Address", ...]] sender: Optional[Tuple["Address", ...]] reply_to: Optional[Tuple["Address", ...]] to: Optional[Tuple["Address", ...]] cc: Optional[Tuple["Address", ...]] bcc: Optional[Tuple["Address", ...]] in_reply_to: bytes message_id: bytes @dataclasses.dataclass class Address: """Represents electronic mail addresses. Used to store addresses in :py:class:`Envelope`. :ivar name: The address "personal name". :ivar route: SMTP source route (rarely used). :ivar mailbox: Mailbox name (what comes just before the @ sign). :ivar host: The host/domain name. As an example, an address header that looks like:: Mary Smith would be represented as:: Address(name=u'Mary Smith', route=None, mailbox=u'mary', host=u'foo.com') See :rfc:`2822` for more detail. See also :py:class:`Envelope` for information about handling of "group syntax". """ name: bytes route: bytes mailbox: bytes host: bytes def __str__(self) -> str: if self.mailbox and self.host: address = to_unicode(self.mailbox) + "@" + to_unicode(self.host) else: address = to_unicode(self.mailbox or self.host) return formataddr((to_unicode(self.name), address)) class SearchIds(List[int]): """ Contains a list of message ids as returned by IMAPClient.search(). The *modseq* attribute will contain the MODSEQ value returned by the server (only if the SEARCH command sent involved the MODSEQ criteria). See :rfc:`4551` for more details. """ def __init__(self, *args: Any): super().__init__(*args) self.modseq: Optional[int] = None _BodyDataType = Tuple[Union[bytes, int, "BodyData"], "_BodyDataType"] class BodyData(_BodyDataType): """ Returned when parsing BODY and BODYSTRUCTURE responses. """ @classmethod def create(cls, response: Tuple[_Atom, ...]) -> "BodyData": # In case of multipart messages we will see at least 2 tuples # at the start. Nest these in to a list so that the returned # response tuple always has a consistent number of elements # regardless of whether the message is multipart or not. if isinstance(response[0], tuple): # Multipart, find where the message part tuples stop parts = [] for i, part in enumerate(response): if isinstance(part, bytes): break if TYPE_CHECKING: assert isinstance(part, tuple) parts.append(part) return cls(([cls.create(part) for part in parts],) + response[i:]) return cls(response) @property def is_multipart(self) -> bool: return isinstance(self[0], list) imapclient-3.0.1/imapclient/testable_imapclient.py000066400000000000000000000021021453256433100223500ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from typing import Any, Dict from unittest.mock import Mock from .imapclient import IMAPClient class TestableIMAPClient(IMAPClient): """Wrapper of :py:class:`imapclient.IMAPClient` that mocks all interaction with real IMAP server. This class should only be used in tests, where you can safely interact with imapclient without running commands on a real IMAP account. """ def __init__(self) -> None: super().__init__("somehost") def _create_IMAP4(self) -> "MockIMAP4": return MockIMAP4() class MockIMAP4(Mock): def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.use_uid = True self.sent = b"" # Accumulates what was given to send() self.tagged_commands: Dict[Any, Any] = {} self._starttls_done = False def send(self, data: bytes) -> None: self.sent += data def _new_tag(self) -> str: return "tag" imapclient-3.0.1/imapclient/tls.py000066400000000000000000000034371453256433100171560ustar00rootroot00000000000000# Copyright (c) 2023, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses """ This module contains IMAPClient's functionality related to Transport Layer Security (TLS a.k.a. SSL). """ import imaplib import io import socket import ssl from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import Buffer def wrap_socket( sock: socket.socket, ssl_context: Optional[ssl.SSLContext], host: str ) -> socket.socket: if ssl_context is None: ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) return ssl_context.wrap_socket(sock, server_hostname=host) class IMAP4_TLS(imaplib.IMAP4): """IMAP4 client class for TLS/SSL connections. Adapted from imaplib.IMAP4_SSL. """ def __init__( self, host: str, port: int, ssl_context: Optional[ssl.SSLContext], timeout: Optional[float] = None, ): self.ssl_context = ssl_context self._timeout = timeout imaplib.IMAP4.__init__(self, host, port) self.file: io.BufferedReader def open( self, host: str = "", port: int = 993, timeout: Optional[float] = None ) -> None: self.host = host self.port = port sock = socket.create_connection( (host, port), timeout if timeout is not None else self._timeout ) self.sock = wrap_socket(sock, self.ssl_context, host) self.file = self.sock.makefile("rb") def read(self, size: int) -> bytes: return self.file.read(size) def readline(self) -> bytes: return self.file.readline() def send(self, data: "Buffer") -> None: self.sock.sendall(data) def shutdown(self) -> None: imaplib.IMAP4.shutdown(self) imapclient-3.0.1/imapclient/typing_imapclient.py000066400000000000000000000001631453256433100220640ustar00rootroot00000000000000from typing import Tuple, Union _AtomPart = Union[None, int, bytes] _Atom = Union[_AtomPart, Tuple["_Atom", ...]] imapclient-3.0.1/imapclient/util.py000066400000000000000000000030201453256433100173150ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import logging from typing import Iterator, Optional, Tuple, Union from . import exceptions logger = logging.getLogger(__name__) def to_unicode(s: Union[bytes, str]) -> str: if isinstance(s, bytes): try: return s.decode("ascii") except UnicodeDecodeError: logger.warning( "An error occurred while decoding %s in ASCII 'strict' mode. Fallback to " "'ignore' errors handling, some characters might have been stripped", s, ) return s.decode("ascii", "ignore") return s def to_bytes(s: Union[bytes, str], charset: str = "ascii") -> bytes: if isinstance(s, str): return s.encode(charset) return s def assert_imap_protocol(condition: bool, message: Optional[bytes] = None) -> None: if not condition: msg = "Server replied with a response that violates the IMAP protocol" if message: # FIXME(jlvillal): This looks wrong as it repeats `msg` twice msg += "{}: {}".format( msg, message.decode(encoding="ascii", errors="ignore") ) raise exceptions.ProtocolError(msg) _TupleAtomPart = Union[None, int, bytes] _TupleAtom = Tuple[Union[_TupleAtomPart, "_TupleAtom"], ...] def chunk(lst: _TupleAtom, size: int) -> Iterator[_TupleAtom]: for i in range(0, len(lst), size): yield lst[i : i + size] imapclient-3.0.1/imapclient/version.py000066400000000000000000000011661453256433100200360ustar00rootroot00000000000000# Copyright (c) 2022, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from typing import Tuple version_info = (3, 0, 1, "final") def _imapclient_version_string(vinfo: Tuple[int, int, int, str]) -> str: major, minor, micro, releaselevel = vinfo v = "%d.%d.%d" % (major, minor, micro) if releaselevel != "final": v += "-" + releaselevel return v version = _imapclient_version_string(version_info) maintainer = "IMAPClient Maintainers" maintainer_email = "imapclient@groups.io" author = "Menno Finlay-Smits" author_email = "inbox@menno.io" imapclient-3.0.1/interact.py000077500000000000000000000003101453256433100160260ustar00rootroot00000000000000#!/usr/bin/env python # Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from imapclient.interact import main main() imapclient-3.0.1/livetest-sample.ini000066400000000000000000000007011453256433100174630ustar00rootroot00000000000000# Sample configuration file for livetest and interact. # An INI file like this is used to specify the IMAP account details to # run "live" tests. These files are also supported by IMAPClient's # interactive shell. [DEFAULT] host = username = password = # These are optional # port = 143 # ssl = false # starttls = false # oauth2 = false # oauth2_client_id = ... # oauth2_client_secret = ... # oauth2_refresh_token = ... imapclient-3.0.1/livetest.py000066400000000000000000001244341453256433100160670ustar00rootroot00000000000000#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import copy import importlib.machinery import importlib.util import os import random import re import socket import string import sys import time import unittest from datetime import datetime from email.utils import make_msgid from imapclient.config import create_client_from_config, parse_config_file from imapclient.exceptions import IMAPClientError from imapclient.fixed_offset import FixedOffset from imapclient.imapclient import ( _dict_bytes_normaliser, DELETED, IMAPClient, RECENT, SocketTimeout, ) from imapclient.response_types import Address, Envelope from imapclient.util import to_bytes, to_unicode # TODO cleaner verbose output: avoid "__main__" and separator between classes SIMPLE_MESSAGE = "Subject: something\r\n\r\nFoo\r\n" # Simple address in To header triggers interesting Fastmail.fm # behaviour with ENVELOPE responses. MULTIPART_MESSAGE = """\ From: Bob Smith To: Some One , foo@foo.com Date: Tue, 16 Mar 2010 16:45:32 +0000 MIME-Version: 1.0 Subject: A multipart message Content-Type: multipart/mixed; boundary="===============1534046211==" --===============1534046211== Content-Type: text/html; charset="us-ascii" Content-Transfer-Encoding: quoted-printable Here is the first part. --===============1534046211== Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Here is the second part. --===============1534046211==-- """.replace( "\n", "\r\n" ) SMILE = "\u263a" MICRO = "\u00b5" SMILE_MESSAGE = b"""\ Subject: stuff Content-Type: text/plain; charset="UTF-8" \xe2\x98\xba """.replace( b"\n", b"\r\n" ) class _TestBase(unittest.TestCase): conf = None use_uid = True @classmethod def setUpClass(cls): client = create_client_from_config(cls.conf) cls.client = client client.use_uid = cls.use_uid cls.condstore_enabled = False if client.has_capability("ENABLE") and client.has_capability("CONDSTORE"): client.enable("CONDSTORE") cls.condstore_enabled = True cls.base_folder = cls.conf.namespace[0] + "__imapclient" cls.folder_delimiter = cls.conf.namespace[1] def setUp(self): self.clear_test_folders() self.unsub_all_test_folders() self.client.create_folder(self.base_folder) self.client.select_folder(self.base_folder) def tearDown(self): self.clear_test_folders() self.unsub_all_test_folders() @classmethod def tearDownClass(cls): cls.client.logout() def skip_unless_capable(self, capability, name=None): if not self.client.has_capability(capability): if not name: name = capability self.skipTest("Server doesn't support %s" % name) def just_folder_names(self, dat): if self.client.folder_encode: gmail_special_prefix = "[" else: gmail_special_prefix = b"[" ret = [] for _, _, folder_name in dat: # gmail's "special" folders start with '[' if not folder_name.startswith(gmail_special_prefix): ret.append(folder_name) return ret def all_test_folder_names(self): return self.just_folder_names(self.client.list_folders(self.base_folder)) def all_sub_test_folder_names(self): return self.just_folder_names(self.client.list_sub_folders(self.base_folder)) def clear_test_folders(self): # Some servers (e.g. newer Dovecot) don't like it when you # delete the currently selected folder. try: self.client.close_folder() except IMAPClientError: pass self.client.folder_encode = False folder_names = sorted( self.all_test_folder_names(), key=self.get_folder_depth, reverse=True ) for folder in folder_names: try: self.client.delete_folder(folder) except IMAPClientError: if not self.is_fastmail(): raise self.client.folder_encode = True def get_folder_depth(self, folder): # Sort folders depth first because some implementations # (e.g. MS Exchange) will delete child folders when a # parent is deleted. return folder.count(self.folder_delimiter.encode("ascii")) def clear_folder(self, folder): self.client.select_folder(folder) self.client.delete_messages(self.client.search()) self.client.expunge() def add_prefix_to_folder(self, folder): if isinstance(folder, bytes): return ( self.base_folder.encode("ascii") + self.folder_delimiter.encode("ascii") + folder ) else: return self.base_folder + self.folder_delimiter + folder def add_prefix_to_folders(self, folders): return [self.add_prefix_to_folder(folder) for folder in folders] def unsub_all_test_folders(self): for folder in self.all_sub_test_folder_names(): self.client.unsubscribe_folder(folder) def is_gmail(self): return self.client._imap.host == "imap.gmail.com" def is_fastmail(self): return ( self.client._imap.host == "mail.messagingengine.com" or self.client._imap.host == "imap.fastmail.com" ) def is_exchange(self): # Assume that these capabilities mean we're talking to MS # Exchange. A bit of a guess really. return ( self.client.has_capability("IMAP4") and self.client.has_capability("AUTH=NTLM") and self.client.has_capability("AUTH=GSSAPI") ) def append_msg(self, msg, folder=None): if not folder: folder = self.base_folder self.client.append(folder, msg) if self.is_gmail(): self.client.noop() class TestGeneral(_TestBase): """ Tests that don't involve message number/UID functionality. """ def test_alternates(self): # Check alternate connection/login configurations. for name, conf in self.conf.alternates.items(): try: client = create_client_from_config(conf) client.logout() except Exception as err: if conf.expect_failure: if not re.search(conf.expect_failure, str(err)): self.fail( "connection test %r failed with %r, expected %r" % (name, err, conf.expect_failure) ) else: self.fail( "connection test %r failed unexpectedly with %r" % (name, err) ) else: if conf.expect_failure: self.fail( "connection test %r didn't fail, expected %r" % (name, conf.expect_failure) ) def test_capabilities(self): caps = self.client.capabilities() self.assertIsInstance(caps, tuple) self.assertGreater(len(caps), 1) for cap in caps: self.assertTrue(self.client.has_capability(cap)) self.assertFalse(self.client.has_capability("WONT EXIST")) def test_namespace(self): self.skip_unless_capable("NAMESPACE") def assertNoneOrTuple(val): assert val is None or isinstance(val, tuple), ( "unexpected namespace value %r" % val ) ns = self.client.namespace() self.assertEqual(len(ns), 3) assertNoneOrTuple(ns.personal) assertNoneOrTuple(ns.other) assertNoneOrTuple(ns.shared) self.assertEqual(ns.personal, ns[0]) self.assertEqual(ns.other, ns[1]) self.assertEqual(ns.shared, ns[2]) def test_unselect_folder(self): if not self.client.has_capability("UNSELECT"): return self.skipTest("Server doesn't support UNSELECT") resp = self.client.select_folder(self.base_folder) self.assertEqual(resp[b"EXISTS"], 0) self.client.search(["ALL"]) self.client.unselect_folder() # To ensure the folder has been selected, check we can't run .search() with self.assertRaises(IMAPClient.Error): self.client.search(["ALL"]) # It should not be possible to unselect a folder if none have been selected yet with self.assertRaises(IMAPClient.Error): self.client.unselect_folder() def test_select_and_close(self): resp = self.client.select_folder(self.base_folder) self.assertEqual(resp[b"EXISTS"], 0) self.assertIsInstance(resp[b"RECENT"], int) self.assertIsInstance(resp[b"FLAGS"], tuple) self.assertGreater(len(resp[b"FLAGS"]), 1) self.client.close_folder() def test_select_read_only(self): self.append_msg(SIMPLE_MESSAGE) untagged = _dict_bytes_normaliser(self.client._imap.untagged_responses) self.assertNotIn(b"READ-ONLY", untagged) resp = self.client.select_folder(self.base_folder, readonly=True) untagged = _dict_bytes_normaliser(self.client._imap.untagged_responses) self.assertIn(b"READ-ONLY", untagged) self.assertEqual(resp[b"EXISTS"], 1) self.assertIsInstance(resp[b"RECENT"], int) self.assertIsInstance(resp[b"FLAGS"], tuple) self.assertGreater(len(resp[b"FLAGS"]), 1) def test_list_folders(self): some_folders = ["simple", b"simple2", "L\xffR"] if not self.is_fastmail(): some_folders.extend([r'test"folder"', rb"foo\bar"]) some_folders = self.add_prefix_to_folders(some_folders) for name in some_folders: self.client.create_folder(name) folders = self.all_test_folder_names() self.assertGreater(len(folders), 1, "No folders visible on server") self.assertIn(self.base_folder, folders) for name in some_folders: self.assertIn(to_unicode(name), folders) # TODO: test LIST with wildcards def test_gmail_xlist(self): caps = self.client.capabilities() if self.is_gmail(): self.assertIn(b"XLIST", caps, "expected XLIST in Gmail's capabilities") def test_xlist(self): self.skip_unless_capable("XLIST") result = self.client.xlist_folders() self.assertGreater(len(result), 0, "No folders returned by XLIST") foundInbox = False for flags, _, _ in result: if rb"\INBOX" in [flag.upper() for flag in flags]: foundInbox = True break if not foundInbox: self.fail("INBOX not returned in XLIST output") def test_subscriptions(self): folders = self.add_prefix_to_folders( [ "foobar", b"foobar2", "stuff & things", b"stuff & things2", "test & \u2622", ] ) for folder in folders: self.client.create_folder(folder) self.client.subscribe_folder(folder) server_folders = self.all_test_folder_names() server_folders.remove(self.base_folder) server_folders.sort() self.assertListEqual(server_folders, sorted(self.all_sub_test_folder_names())) for folder in folders: self.client.unsubscribe_folder(folder) self.assertListEqual(self.all_sub_test_folder_names(), []) # Exchange doesn't return an error when subscribing to a # non-existent folder if not self.is_exchange(): self.assertRaises( IMAPClientError, self.client.subscribe_folder, "this folder is not likely to exist", ) def test_folders(self): self.assertTrue(self.client.folder_exists(self.base_folder)) self.assertFalse(self.client.folder_exists("this is very unlikely to exist")) folders = [ "foobar", "123", b"foobar", b"123", ] if not self.is_fastmail(): # Fastmail doesn't appear to like double quotes in folder names folders.extend( [ '"foobar"', 'foo "bar"', b'"foobar"', b'foo "bar"', ] ) # Run folder tests with folder_encode off self.run_folder_tests(folders, False) # Now with folder_encode on, adding in names that only work # when this is enabled. folders.extend( [ "test & \u2622", "stuff & things", b"stuff & things", ] ) self.run_folder_tests(folders, True) def run_folder_tests(self, folder_names, folder_encode): self.client.folder_encode = folder_encode try: folder_names = self.add_prefix_to_folders(folder_names) for folder in folder_names: self.assertFalse(self.client.folder_exists(folder)) self.client.create_folder(folder) self.assertTrue(self.client.folder_exists(folder)) self.assertIn( to_unicode(folder) if folder_encode else to_bytes(folder), self.all_test_folder_names(), ) self.client.select_folder(folder) self.client.close_folder() self.client.delete_folder(folder) self.assertFalse(self.client.folder_exists(folder)) finally: self.client.folder_encode = True def test_rename_folder(self): folders = self.add_prefix_to_folders( [ "foobar", b"foobar2", "stuff & things", b"stuff & things2", "123", b"1232", "test & \u2622", ] ) for folder in folders: self.client.create_folder(folder) if isinstance(folder, bytes): new_folder = folder + b"x" else: new_folder = folder + "x" resp = self.client.rename_folder(folder, new_folder) self.assertIsInstance(resp, bytes) self.assertTrue(len(resp) > 0) self.assertFalse(self.client.folder_exists(folder)) self.assertTrue(self.client.folder_exists(new_folder)) def test_status(self): # Default behaviour should return 5 keys self.assertEqual(len(self.client.folder_status(self.base_folder)), 5) new_folder = self.add_prefix_to_folder("test \u2622") self.client.create_folder(new_folder) try: status = self.client.folder_status(new_folder) self.assertEqual(status[b"MESSAGES"], 0) self.assertEqual(status[b"RECENT"], 0) self.assertEqual(status[b"UNSEEN"], 0) # Add a message to the folder, it should show up now. self.append_msg(SIMPLE_MESSAGE, new_folder) status = self.client.folder_status(new_folder) self.assertEqual(status[b"MESSAGES"], 1) if not self.is_gmail(): self.assertEqual(status[b"RECENT"], 1) self.assertEqual(status[b"UNSEEN"], 1) finally: self.client.delete_folder(new_folder) def test_idle(self): if not self.client.has_capability("IDLE"): return self.skipTest("Server doesn't support IDLE") # Start main connection idling self.client.select_folder(self.base_folder) self.client.idle() try: # Start a new connection and upload a new message client2 = create_client_from_config(self.conf) self.addCleanup(quiet_logout, client2) client2.select_folder(self.base_folder) client2.append(self.base_folder, SIMPLE_MESSAGE) # Check for the idle data # Notification can take a while to arrive. In the meantime we may # receive some keep alive like (b'OK', b'Still here') that # maintain the TCP connection opened. start_time = time.time() responses = [] while time.time() - start_time < 60: responses = self.client.idle_check(timeout=10) if (1, b"EXISTS") in responses: break finally: text, more_responses = self.client.idle_done() self.assertIn((1, b"EXISTS"), responses) self.assertTrue(isinstance(text, bytes)) self.assertGreater(len(text), 0) self.assertTrue(isinstance(more_responses, list)) # Check for IDLE data returned by idle_done() self.client.idle() try: client2.select_folder(self.base_folder) client2.append(self.base_folder, SIMPLE_MESSAGE) time.sleep(10) # Allow some time for the IDLE response to be sent finally: text, responses = self.client.idle_done() if not responses: # The append was not yet picked by the other connection. # This happens with some servers. return self.assertIn((2, b"EXISTS"), responses) self.assertTrue(isinstance(text, bytes)) self.assertGreater(len(text), 0) def test_noop(self): self.client.select_folder(self.base_folder) # Initially there should be no responses text, resps = self.client.noop() self.assertTrue(isinstance(text, bytes)) self.assertGreater(len(text), 0) self.assertEqual(resps, []) # Start a new connection and upload a new message client2 = create_client_from_config(self.conf) self.addCleanup(quiet_logout, client2) client2.select_folder(self.base_folder) client2.append(self.base_folder, SIMPLE_MESSAGE) # Check for this addition in the NOOP data msg, resps = self.client.noop() self.assertTrue(isinstance(text, bytes)) self.assertGreater(len(text), 0) self.assertTrue(isinstance(resps, list)) self.assertIn((1, b"EXISTS"), resps) class TestSocketTimeout(unittest.TestCase): """ Tests SocketTimeout instanciation and usage. We're overriding intentionally the timeout from the config file with unrealistic numbers to do that without altering other tests suite. """ conf = None def setUp(self): self.client = None def tearDown(self): if self.client: quiet_logout(self.client) def test_small_connection_timeout_fail(self): self.conf.timeout = SocketTimeout(connect=0.001, read=10) with self.assertRaises(socket.timeout): self.client = create_client_from_config(self.conf) def test_small_read_timeout_fail(self): """ For ease, the login operation use the read/write timeout. To make the test pass, we don't login once connected but simply try a 'noop', that should not be able to complete in under a such a small time. """ self.conf.timeout = SocketTimeout(connect=30, read=0.00001) self.client = create_client_from_config(self.conf, login=False) with self.assertRaises(socket.timeout): self.client.noop() def createUidTestClass(conf, use_uid): class LiveTest(_TestBase): """ Tests could possibily involve message number/UID functionality or change behaviour based on the use_uid attribute should go here. They are tested twice: once with use_uid on and once with it off. """ def test_append_unicode(self): self.check_append(SIMPLE_MESSAGE, SIMPLE_MESSAGE) def test_append_bytes(self): self.check_append(SIMPLE_MESSAGE.encode("ascii"), SIMPLE_MESSAGE) def check_append(self, in_message, out_message): # Message time microseconds are set to 0 because the server will return # time with only seconds precision. msg_time = datetime.now().replace(microsecond=0) # Append message resp = self.client.append( self.base_folder, in_message, ("abc", "def"), msg_time ) self.assertIsInstance(resp, bytes) # Retrieve the just added message and check that all looks well self.assertEqual(self.client.select_folder(self.base_folder)[b"EXISTS"], 1) resp = self.client.fetch( self.client.search()[0], ("RFC822", "FLAGS", "INTERNALDATE") ) self.assertEqual(len(resp), 1) msginfo = tuple(resp.values())[0] # Time should match the time we specified returned_msg_time = msginfo[b"INTERNALDATE"] self.assertIsNone(returned_msg_time.tzinfo) self.assertEqual(returned_msg_time, msg_time) # Flags should be the same self.assertIn(b"abc", msginfo[b"FLAGS"]) self.assertIn(b"def", msginfo[b"FLAGS"]) # Message body should match self.assertEqual(msginfo[b"RFC822"], to_bytes(out_message)) def test_flags(self): self.append_msg(SIMPLE_MESSAGE) msg_id = self.client.search()[0] def _flagtest(func, args, expected_flags): answer = func(msg_id, *args) self.assertTrue(msg_id in answer) answer_flags = set(answer[msg_id]) answer_flags.discard(RECENT) # Might be present but don't care self.assertSetEqual( answer_flags, set(to_bytes(f) for f in expected_flags) ) base_flags = ["abc", "def"] _flagtest(self.client.set_flags, [base_flags], base_flags) _flagtest(self.client.get_flags, [], base_flags) _flagtest(self.client.add_flags, ["boo"], base_flags + ["boo"]) _flagtest(self.client.remove_flags, ["boo"], base_flags) def test_gmail_labels(self): self.skip_unless_capable("X-GM-EXT-1", "labels") self.append_msg(SIMPLE_MESSAGE) msg_id = self.client.search()[0] def _labeltest(func, args, expected_labels): answer = func(msg_id, *args) self.assertEqual(list(answer.keys()), [msg_id]) actual_labels = set(answer[msg_id]) self.assertSetEqual(actual_labels, set(expected_labels)) FOO = "_imapclient_foo" BAR = "_imapclient_bar" BAZ = "_imapclient_bÂz" all_labels = [FOO, BAR, BAZ] base_labels = [FOO, BAR] try: _labeltest(self.client.set_gmail_labels, [base_labels], base_labels) _labeltest(self.client.get_gmail_labels, [], base_labels) _labeltest(self.client.add_gmail_labels, [BAZ], all_labels) _labeltest(self.client.remove_gmail_labels, [BAZ], base_labels) finally: # Clean up folders created by assigning labels. for label in all_labels: if self.client.folder_exists(label): self.client.delete_folder(label) def test_search(self): # Add some test messages msg_tmpl = "Subject: %s\r\n\r\nBody" subjects = ("a", "b", "c") for subject in subjects: msg = msg_tmpl % subject if subject == "c": flags = (DELETED,) else: flags = () self.client.append(self.base_folder, msg, flags) self.client.noop() # For Gmail # Check we see all messages messages_all = self.client.search("ALL") if self.is_gmail(): # Gmail seems to never return deleted items. self.assertEqual(len(messages_all), len(subjects) - 1) else: self.assertEqual(len(messages_all), len(subjects)) self.assertListEqual(self.client.search(), messages_all) # Check default if not self.is_gmail(): # Delete behaviour is dependent on a setting with Gmail. self.assertEqual(len(self.client.search("DELETED")), 1) self.assertEqual( len(self.client.search(["NOT", "DELETED"])), len(subjects) - 1 ) self.assertEqual( len(self.client.search(["NOT", "DELETED", "SMALLER", 500])), len(subjects) - 1, ) self.assertEqual( len(self.client.search(["NOT", "DELETED", "SMALLER", 5])), 0 ) self.assertEqual( len(self.client.search(["NOT", "DELETED", "SUBJECT", "a"])), 1 ) self.assertEqual( len(self.client.search(["NOT", "DELETED", "SUBJECT", "c"])), 0 ) # Exercise "raw" strings where all criteria are provided as a single string. self.assertEqual(len(self.client.search('SUBJECT "a" NOT DELETED')), 1) self.assertEqual( len(self.client.search("NOT DELETED SUBJECT SMALLER 5")), 0 ) def test_search_with_modseq(self): # CONDSTORE (RFC 4551) means that the server supports the # MODSEQ search criteria and response. if not self.client.has_capability("CONDSTORE"): return self.skipTest("Server doesn't support CONDSTORE") # Remember the initial MODSEQ initial_modseq = self.client.select_folder(self.base_folder)[ b"HIGHESTMODSEQ" ] # Add a message so that the MODSEQ increases self.append_msg(SIMPLE_MESSAGE) # Ensure the message is seen and the new MODSEQ value is returned ids = self.client.search(["MODSEQ", str(initial_modseq)]) self.assertEqual(len(ids), 1) self.assertGreater(ids.modseq, initial_modseq) def test_search_with_unicode(self): self.client.append(self.base_folder, SMILE_MESSAGE) self.assertEqual( len(self.client.search(["BODY", SMILE], charset="UTF-8")), 1 ) self.assertEqual( len(self.client.search(["BODY", MICRO], charset="UTF-8")), 0 ) # Try multiple criteria too self.assertEqual( len( self.client.search( ["TEXT", SMILE, "NOT", "DELETED"], charset="UTF-8" ) ), 1, ) def test_gmail_search(self): self.skip_unless_capable("X-GM-EXT-1", "Gmail search") random_string = "".join(random.sample(string.ascii_letters * 20, 64)) msg = "Subject: something\r\n\r\nFoo\r\n%s\r\n" % random_string self.append_msg(msg) self.append_msg(SMILE_MESSAGE) ids = self.client.gmail_search(random_string) self.assertEqual(len(ids), 1) ids = self.client.gmail_search("s0mewh4t unl1kely") self.assertEqual(len(ids), 0) # Test encoded queries ids = self.client.gmail_search(MICRO) self.assertEqual(len(ids), 0) ids = self.client.gmail_search(SMILE) self.assertGreater(len(ids), 0) def test_sort(self): self.skip_unless_capable("SORT") # Add some test messages msg_tmpl = "Subject: Test\r\n\r\nBody" num_lines = (10, 20, 30) line = "\n" + ("x" * 72) for line_cnt in num_lines: msg = msg_tmpl + (line * line_cnt) self.client.append(self.base_folder, msg) messages = self.client.sort("REVERSE SIZE") self.assertEqual(len(messages), 3) first_id = messages[0] expected = [first_id, first_id - 1, first_id - 2] self.assertListEqual(messages, expected) messages = self.client.sort("REVERSE SIZE", ["NOT", "DELETED"]) self.assertListEqual(messages, expected) messages = self.client.sort("REVERSE SIZE", "NOT DELETED") self.assertListEqual(messages, expected) def test_sort_with_unicode(self): self.skip_unless_capable("SORT") self.append_msg(SMILE_MESSAGE) messages = self.client.sort("ARRIVAL", ["TEXT", SMILE]) self.assertEqual(len(messages), 1) messages = self.client.sort("ARRIVAL", ["TEXT", MICRO]) self.assertEqual(len(messages), 0) def test_thread(self): self.skip_unless_capable("THREAD=REFERENCES") msg_tmpl = "Subject: %s\r\n\r\nBody" subjects = ("a", "b", "c") for subject in subjects: self.append_msg(msg_tmpl % subject) threads = self.client.thread() self.assertEqual(len(threads), 3) self.assertIsInstance(threads[0], tuple) first_id = threads[0][0] expected = ((first_id,), (first_id + 1,), (first_id + 2,)) self.assertTupleEqual(threads, expected) threads = self.client.thread(criteria=["NOT", "DELETED"]) self.assertTupleEqual(threads, expected) threads = self.client.thread(criteria="NOT DELETED") self.assertTupleEqual(threads, expected) def test_thread_with_unicode(self): self.skip_unless_capable("THREAD=REFERENCES") self.append_msg(SMILE_MESSAGE) threads = self.client.thread(criteria=["TEXT", SMILE]) self.assertEqual(len(threads), 1) self.assertEqual(len(threads[0]), 1) threads = self.client.thread(criteria=["TEXT", MICRO]) self.assertEqual(len(threads), 0) def test_copy(self): self.append_msg(SIMPLE_MESSAGE) target_folder = self.add_prefix_to_folder("target") self.client.create_folder(target_folder) msg_id = self.client.search()[0] self.client.copy(msg_id, target_folder) self.client.select_folder(target_folder) msgs = self.client.search() self.assertEqual(len(msgs), 1) msg_id = msgs[0] self.assertIn( b"something", self.client.fetch(msg_id, ["RFC822"])[msg_id][b"RFC822"] ) def test_move(self): self.skip_unless_capable("MOVE") self.append_msg(SIMPLE_MESSAGE) target_folder = self.add_prefix_to_folder("target") self.client.create_folder(target_folder) found_messages = self.client.search() msg_id = found_messages[0] self.client.move(msg_id, target_folder) self.assertEqual(len(self.client.search()), len(found_messages) - 1) self.client.select_folder(target_folder) msgs = self.client.search() self.assertEqual(len(msgs), 1) msg_id = msgs[0] self.assertIn( b"something", self.client.fetch(msg_id, ["RFC822"])[msg_id][b"RFC822"] ) def test_fetch(self): # Generate a fresh message-id each time because Gmail is # clever and will treat appends of messages with # previously seen message-ids as the same message. This # breaks our tests when the test message is updated. msg_id_header = make_msgid() msg = ("Message-ID: %s\r\n" % msg_id_header) + MULTIPART_MESSAGE self.client.select_folder(self.base_folder) self.append_msg(msg) self.client.normalise_times = False fields = ["RFC822", b"FLAGS", "INTERNALDATE", "ENVELOPE"] msg_id = self.client.search()[0] resp = self.client.fetch(msg_id, fields) self.assertEqual(len(resp), 1) msginfo = resp[msg_id] extra_fields = [b"SEQ"] if self.condstore_enabled: extra_fields.append(b"MODSEQ") self.assertSetEqual( set(msginfo.keys()), set([to_bytes(f) for f in fields] + extra_fields), ) self.assertEqual(msginfo[b"SEQ"], 1) self.assertEqual(msginfo[b"RFC822"], to_bytes(msg)) self.assertIsInstance(msginfo[b"INTERNALDATE"], datetime) self.assertIsInstance(msginfo[b"FLAGS"], tuple) self.assertSequenceEqual( msginfo[b"ENVELOPE"], Envelope( datetime(2010, 3, 16, 16, 45, 32, tzinfo=FixedOffset(0)), b"A multipart message", (Address(b"Bob Smith", None, b"bob", b"smith.com"),), (Address(b"Bob Smith", None, b"bob", b"smith.com"),), (Address(b"Bob Smith", None, b"bob", b"smith.com"),), ( Address(b"Some One", None, b"some", b"one.com"), Address(None, None, b"foo", b"foo.com"), ), None, None, None, to_bytes(msg_id_header), ), ) def test_partial_fetch(self): self.client.append(self.base_folder, MULTIPART_MESSAGE) self.client.select_folder(self.base_folder) msg_id = self.client.search()[0] resp = self.client.fetch(msg_id, ["BODY[]<0.20>"]) body = resp[msg_id][b"BODY[]<0>"] self.assertEqual(len(body), 20) self.assertTrue(body.startswith(b"From: Bob Smith")) resp = self.client.fetch(msg_id, ["BODY[]<2.25>"]) body = resp[msg_id][b"BODY[]<2>"] self.assertEqual(len(body), 25) self.assertTrue(body.startswith(b"om: Bob Smith")) def test_fetch_modifiers(self): # CONDSTORE (RFC 4551) provides a good way to use FETCH # modifiers but it isn't always available. if not self.client.has_capability("CONDSTORE"): return self.skipTest("Server doesn't support CONDSTORE") # Get the starting MODSEQ modseq = self.client.select_folder(self.base_folder)[b"HIGHESTMODSEQ"] # Add a message so that the MODSEQ gets bumped self.append_msg(SIMPLE_MESSAGE) msg_id = self.client.search()[0] # Request changes since the starting MODSEQ: this should # return the new message. resp = self.client.fetch(msg_id, ["FLAGS"], ["CHANGEDSINCE %d" % modseq]) new_modseq = resp[msg_id][b"MODSEQ"][0] self.assertGreater(new_modseq, modseq) # Now asked for changes since the MODSEQ on the added # message. These shouldn't be any. resp = self.client.fetch( msg_id, ["FLAGS"], ["CHANGEDSINCE %d" % new_modseq] ) self.assertEqual(resp, {}) def test_BODYSTRUCTURE(self): self.client.select_folder(self.base_folder) self.append_msg(SIMPLE_MESSAGE) self.append_msg(MULTIPART_MESSAGE) msgs = self.client.search() fetched = self.client.fetch(msgs, ["BODY", "BODYSTRUCTURE"]) # The expected test data is the same for BODY and BODYSTRUCTURE # since we can't predicate what the server we're testing against # will return. expected = ( b"text", b"plain", (b"charset", b"us-ascii"), None, None, b"7bit", 5, 1, ) self.check_BODYSTRUCTURE( expected, fetched[msgs[0]][b"BODY"], multipart=False ) self.check_BODYSTRUCTURE( expected, fetched[msgs[0]][b"BODYSTRUCTURE"], multipart=False ) expected = ( [ ( b"text", b"html", (b"charset", b"us-ascii"), None, None, b"quoted-printable", 55, 3, ), ( b"text", b"plain", (b"charset", b"us-ascii"), None, None, b"7bit", 26, 1, ), ], b"mixed", (b"boundary", b"===============1534046211=="), ) self.check_BODYSTRUCTURE( expected, fetched[msgs[1]][b"BODY"], multipart=True ) self.check_BODYSTRUCTURE( expected, fetched[msgs[1]][b"BODYSTRUCTURE"], multipart=True ) def check_BODYSTRUCTURE(self, expected, actual, multipart=None): if multipart is not None: self.assertEqual(actual.is_multipart, multipart) if have_matching_types(expected, actual, (list, tuple)): # BODYSTRUCTURE lengths can various according to the # server so compare up until what is returned for pair in zip(expected, actual): self.check_BODYSTRUCTURE(*pair) elif expected == (b"charset", b"us-ascii") and actual is None: pass # Some servers don't return a charset when it's us-ascii else: self.assertEqual(maybe_lower(expected), maybe_lower(actual)) def test_expunge(self): self.client.select_folder(self.base_folder) # Test empty mailbox text, resps = self.client.expunge() self.assertTrue(isinstance(text, bytes)) self.assertGreater(len(text), 0) # Some servers return nothing while others (e.g. Exchange) return (0, 'EXISTS') self.assertIn(resps, ([], [(0, b"EXISTS")])) # Now try with a message to expunge self.client.append(self.base_folder, SIMPLE_MESSAGE, flags=[DELETED]) msg, resps = self.client.expunge() self.assertTrue(isinstance(text, bytes)) self.assertGreater(len(text), 0) self.assertTrue(isinstance(resps, list)) if not self.is_gmail(): # GMail has an auto-expunge feature which might be # on. EXPUNGE won't return anything in this case self.assertIn((1, b"EXPUNGE"), resps) def test_uid_expunge(self): if not self.client.use_uid: self.skipTest("test instance not configured for UID operations") if self.is_gmail(): self.skipTest( "Gmail's auto-expunge feature makes this hard to test there" ) folder = self.add_prefix_to_folder("test_uid_expunge") self.client.create_folder(folder) self.client.select_folder(folder) for i in range(3): self.client.append( folder, "Subject: msg %d\r\n\r\nbody %d\r\n\r\n" % (i, i) ) messages = self.client.search() self.assertEqual(len(messages), 3) m0 = messages[0] m1 = messages[1] # delete 2 messages, but only expunge one of them self.client.delete_messages([messages[0], messages[2]]) self.client.expunge(messages[2]) messages = self.client.search() self.assertEqual(len(messages), 2) self.assertIn(m0, messages) self.assertIn(m1, messages) def test_getacl(self): self.skip_unless_capable("ACL") folder = self.add_prefix_to_folder("test_acl") self.client.create_folder(folder) who = to_bytes(conf["username"]) rights = self.client.getacl(folder) self.assertIn(who, [u for u, r in rights]) LiveTest.conf = conf LiveTest.use_uid = use_uid return LiveTest def quiet_logout(client): """Log out a connection, ignoring errors (say because the connection is down)""" try: client.logout() except IMAPClientError: pass def maybe_lower(val): if isinstance(val, (str, bytes)): return val.lower() return val def have_matching_types(a, b, type_or_types): """True if a and b are instances of the same type and that type is one of type_or_types. """ if not isinstance(a, type_or_types): return False return isinstance(b, type(a)) def argv_error(msg): print(msg, file=sys.stderr) print(file=sys.stderr) print( "usage: %s [ optional unittest arguments ]" % sys.argv[0], file=sys.stderr, ) sys.exit(1) def parse_argv(): args = sys.argv[1:] if not args: argv_error( "Please specify a host configuration file. See livetest-sample.ini for an example." ) ini_path = sys.argv.pop(1) # 2nd arg should be the INI file if not os.path.isfile(ini_path): argv_error("%r is not a livetest INI file" % ini_path) host_config = parse_config_file(ini_path) return host_config def probe_host(config): client = create_client_from_config(config) ns = client.namespace() client.logout() if not ns.personal: raise RuntimeError("Can't run tests: IMAP account has no personal namespace") return ns.personal[0] # Use first personal namespace def main(): host_config = parse_argv() namespace = probe_host(host_config) host_config.namespace = namespace spec = importlib.machinery.ModuleSpec("livetests", None) live_test_mod = importlib.util.module_from_spec(spec) sys.modules["livetests"] = live_test_mod def add_test_class(klass, name=None): if name is None: name = klass.__name__ else: klass.__name__ = name setattr(live_test_mod, name, klass) TestGeneral.conf = host_config TestSocketTimeout.conf = copy.copy(host_config) add_test_class(TestGeneral) add_test_class(TestSocketTimeout) add_test_class(createUidTestClass(host_config, use_uid=True), "TestWithUIDs") add_test_class(createUidTestClass(host_config, use_uid=False), "TestWithoutUIDs") unittest.main(module="livetests") if __name__ == "__main__": main() imapclient-3.0.1/pyproject.toml000066400000000000000000000041571453256433100165710ustar00rootroot00000000000000[tool.isort] profile = "black" multi_line_output = 3 order_by_type = false [tool.mypy] files = "." exclude = [ "doc/.*", "examples/.*", "tests/.*", ] show_error_codes = true # 'strict = true' is equivalent to: # --check-untyped-defs # --disallow-any-generics # --disallow-incomplete-defs # --disallow-subclassing-any # --disallow-untyped-calls # --disallow-untyped-decorators # --disallow-untyped-defs # --extra-checks # --no-implicit-reexport # --strict-equality # --warn-redundant-casts # --warn-return-any # --warn-unused-configs # --warn-unused-ignores check_untyped_defs = true disallow_any_generics = true disallow_incomplete_defs = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_decorators = true disallow_untyped_defs = true extra_checks = true no_implicit_reexport = true strict_equality = true warn_redundant_casts = true warn_return_any = true warn_unused_configs = true warn_unused_ignores = true # Overrides for currently untyped modules [[tool.mypy.overrides]] module = [ "imapclient.imapclient", "livetest", ] ignore_errors = true [tool.pylint.messages_control] max-line-length = 88 jobs = 0 # Use auto-detected number of multiple processes to speed up Pylint. # TODO(jlvillal): Work on removing these disables over time. disable = [ "attribute-defined-outside-init", "broad-exception-caught", "consider-using-f-string", "consider-using-ternary", "deprecated-method", "fixme", "import-error", "import-outside-toplevel", "inconsistent-return-statements", "invalid-name", "line-too-long", "missing-class-docstring", "missing-function-docstring", "missing-module-docstring", "no-value-for-parameter", "protected-access", "raise-missing-from", "signature-differs", "simplifiable-if-statement", "too-few-public-methods", "too-many-arguments", "too-many-boolean-expressions", "too-many-branches", "too-many-instance-attributes", "too-many-lines", "too-many-public-methods", "try-except-raise", "undefined-loop-variable", "unused-argument", ] imapclient-3.0.1/requirements-dev.txt000066400000000000000000000001231453256433100177020ustar00rootroot00000000000000sphinx==6.2.1 black==23.11.0 flake8==6.1.0 isort==5.12.0 mypy==1.6.1 pylint==3.0.2 imapclient-3.0.1/setup.cfg000066400000000000000000000003221453256433100154640ustar00rootroot00000000000000[aliases] # Make sure the sphinx docs are built each time a dist is built bdist = build_sphinx bdist sdist = build_sphinx sdist [build_sphinx] build-dir = doc source-dir = doc/src [bdist_wheel] universal = 1 imapclient-3.0.1/setup.py000066400000000000000000000043721453256433100153660ustar00rootroot00000000000000#!/usr/bin/env python3 # Copyright (c) 2023, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from os import path from typing import Dict from setuptools import setup # type: ignore[import-untyped] # Read version info here = path.dirname(__file__) version_file = path.join(here, "imapclient", "version.py") info: Dict[str, str] = {} exec(open(version_file).read(), {}, info) desc = """\ IMAPClient is an easy-to-use, Pythonic and complete IMAP client library. Features: * Arguments and return values are natural Python types. * IMAP server responses are fully parsed and readily usable. * IMAP unique message IDs (UIDs) are handled transparently. * Internationalised mailbox names are transparently handled. * Time zones are correctly handled. * Convenience methods are provided for commonly used functionality. * Exceptions are raised when errors occur. Python versions 3.7 through 3.11 are officially supported. IMAPClient includes comprehensive units tests and automated functional tests that can be run against a live IMAP server. """ doc_deps = ["sphinx"] setup( name="IMAPClient", description="Easy-to-use, Pythonic and complete IMAP client library", keywords="imap client email mail", version=info["version"], maintainer=info["maintainer"], maintainer_email=info["maintainer_email"], author=info["author"], author_email=info["author_email"], license="3-Clause BSD License", url="https://github.com/mjs/imapclient/", packages=["imapclient"], package_data=dict(imapclient=["examples/*.py"]), extras_require={"doc": doc_deps}, long_description=desc, python_requires=">=3.7.0", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Communications :: Email :: Post-Office :: IMAP", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Networking", ], ) imapclient-3.0.1/tests/000077500000000000000000000000001453256433100150105ustar00rootroot00000000000000imapclient-3.0.1/tests/__init__.py000066400000000000000000000002031453256433100171140ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses imapclient-3.0.1/tests/imapclient_test.py000066400000000000000000000003001453256433100205370ustar00rootroot00000000000000import unittest from imapclient.testable_imapclient import TestableIMAPClient as IMAPClient class IMAPClientTest(unittest.TestCase): def setUp(self): self.client = IMAPClient() imapclient-3.0.1/tests/test_auth.py000066400000000000000000000024731453256433100173700ustar00rootroot00000000000000# Copyright (c) 2016, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from imapclient.exceptions import LoginError from .imapclient_test import IMAPClientTest class TestPlainLogin(IMAPClientTest): def assert_authenticate_call(self, expected_auth_string): authenticate = self.client._imap.authenticate self.assertEqual(authenticate.call_count, 1) auth_type, auth_func = authenticate.call_args[0] self.assertEqual(auth_type, "PLAIN") self.assertEqual(auth_func(None), expected_auth_string) def test_simple(self): self.client._imap.authenticate.return_value = ("OK", [b"Success"]) result = self.client.plain_login("user", "secret") self.assertEqual(result, b"Success") self.assert_authenticate_call("\0user\0secret") def test_fail(self): self.client._imap.authenticate.return_value = ("NO", [b"Boom"]) self.assertRaises(LoginError, self.client.plain_login, "user", "secret") def test_with_authorization_identity(self): self.client._imap.authenticate.return_value = ("OK", [b"Success"]) result = self.client.plain_login("user", "secret", "authid") self.assertEqual(result, b"Success") self.assert_authenticate_call("authid\0user\0secret") imapclient-3.0.1/tests/test_datetime_util.py000066400000000000000000000053741453256433100212630ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import unittest from datetime import date, datetime from unittest.mock import patch from imapclient.datetime_util import ( datetime_to_INTERNALDATE, datetime_to_native, format_criteria_date, parse_to_datetime, ) from imapclient.fixed_offset import FixedOffset class TestParsing(unittest.TestCase): def check_normalised_and_not(self, in_string, expected_datetime): self.assertEqual( parse_to_datetime(in_string), datetime_to_native(expected_datetime) ) self.assertEqual( parse_to_datetime(in_string, normalise=False), expected_datetime ) def test_rfc822_style(self): self.check_normalised_and_not( b"Sun, 24 Mar 2013 22:06:10 +0200", datetime(2013, 3, 24, 22, 6, 10, 0, FixedOffset(120)), ) def test_internaldate_style(self): self.check_normalised_and_not( b" 9-Feb-2007 17:08:08 -0430", datetime(2007, 2, 9, 17, 8, 8, 0, FixedOffset(-4 * 60 - 30)), ) self.check_normalised_and_not( b"19-Feb-2007 17:08:08 0400", datetime(2007, 2, 19, 17, 8, 8, 0, FixedOffset(4 * 60)), ) def test_dots_for_time_separator(self): # As reported in issue #154. self.check_normalised_and_not( b"Sat, 8 May 2010 16.03.09 +0200", datetime(2010, 5, 8, 16, 3, 9, 0, FixedOffset(120)), ) self.check_normalised_and_not( b"Tue, 18 May 2010 16.03.09 -0200", datetime(2010, 5, 18, 16, 3, 9, 0, FixedOffset(-120)), ) self.check_normalised_and_not( b"Wednesday,18 August 2010 16.03.09 -0200", datetime(2010, 8, 18, 16, 3, 9, 0, FixedOffset(-120)), ) def test_invalid(self): self.assertRaises(ValueError, parse_to_datetime, b"ABC") class TestDatetimeToINTERNALDATE(unittest.TestCase): def test_with_timezone(self): dt = datetime(2009, 1, 2, 3, 4, 5, 0, FixedOffset(2 * 60 + 30)) self.assertEqual(datetime_to_INTERNALDATE(dt), "02-Jan-2009 03:04:05 +0230") @patch("imapclient.datetime_util.FixedOffset.for_system") def test_without_timezone(self, for_system): dt = datetime(2009, 1, 2, 3, 4, 5, 0) for_system.return_value = FixedOffset(-5 * 60) self.assertEqual(datetime_to_INTERNALDATE(dt), "02-Jan-2009 03:04:05 -0500") class TestCriteriaDateFormatting(unittest.TestCase): def test_basic(self): self.assertEqual(format_criteria_date(date(1996, 2, 22)), b"22-Feb-1996") def test_single_digit_day(self): self.assertEqual(format_criteria_date(date(1996, 4, 4)), b"04-Apr-1996") imapclient-3.0.1/tests/test_enable.py000066400000000000000000000040041453256433100176450ustar00rootroot00000000000000# Copyright (c) 2017, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from unittest.mock import Mock from imapclient.exceptions import IllegalStateError from .imapclient_test import IMAPClientTest class TestEnable(IMAPClientTest): def setUp(self): super(TestEnable, self).setUp() self.command = Mock() self.client._raw_command_untagged = self.command self.client._imap.state = "AUTH" self.client._cached_capabilities = [b"ENABLE"] def test_success(self): self.command.return_value = b"CONDSTORE" resp = self.client.enable("CONDSTORE") self.command.assert_called_once_with( b"ENABLE", [b"CONDSTORE"], uid=False, response_name="ENABLED", unpack=True ) self.assertEqual(resp, [b"CONDSTORE"]) def test_failed1(self): # When server returns an empty ENABLED response self.command.return_value = b"" resp = self.client.enable("FOO") self.command.assert_called_once_with( b"ENABLE", [b"FOO"], uid=False, response_name="ENABLED", unpack=True ) self.assertEqual(resp, []) def test_failed2(self): # When server returns no ENABLED response self.command.return_value = None resp = self.client.enable("FOO") self.command.assert_called_once_with( b"ENABLE", [b"FOO"], uid=False, response_name="ENABLED", unpack=True ) self.assertEqual(resp, []) def test_multiple(self): self.command.return_value = b"FOO BAR" resp = self.client.enable("FOO", "BAR") self.command.assert_called_once_with( b"ENABLE", [b"FOO", b"BAR"], uid=False, response_name="ENABLED", unpack=True ) self.assertEqual(resp, [b"FOO", b"BAR"]) def test_wrong_state(self): self.client._imap.state = "SELECTED" self.assertRaises( IllegalStateError, self.client.enable, "FOO", ) imapclient-3.0.1/tests/test_fixed_offset.py000066400000000000000000000044261453256433100210740ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import unittest from datetime import timedelta from unittest.mock import DEFAULT, Mock, patch from imapclient.fixed_offset import FixedOffset class TestFixedOffset(unittest.TestCase): def _check(self, offset, expected_delta, expected_name): self.assertEqual(offset.utcoffset(None), expected_delta) self.assertEqual(offset.tzname(None), expected_name) self.assertEqual(offset.dst(None), timedelta(0)) def test_GMT(self): self._check(FixedOffset(0), timedelta(0), "+0000") def test_positive(self): self._check(FixedOffset(30), timedelta(minutes=30), "+0030") self._check(FixedOffset(2 * 60), timedelta(hours=2), "+0200") self._check(FixedOffset(11 * 60 + 30), timedelta(hours=11, minutes=30), "+1130") def test_negative(self): self._check(FixedOffset(-30), timedelta(minutes=-30), "-0030") self._check(FixedOffset(-2 * 60), timedelta(hours=-2), "-0200") self._check( FixedOffset(-11 * 60 - 30), timedelta(minutes=(-11 * 60) - 30), "-1130" ) @patch.multiple( "imapclient.fixed_offset.time", daylight=True, timezone=15 * 60 * 60, localtime=DEFAULT, ) def test_for_system_DST_not_active(self, localtime): localtime_mock = Mock() localtime_mock.tm_isdst = False localtime.return_value = localtime_mock offset = FixedOffset.for_system() self.assertEqual(offset.tzname(None), "-1500") @patch.multiple( "imapclient.fixed_offset.time", daylight=True, altzone=15 * 60 * 60, localtime=DEFAULT, ) def test_for_system_DST_active(self, localtime): localtime_mock = Mock() localtime_mock.tm_isdst = True localtime.return_value = localtime_mock offset = FixedOffset.for_system() self.assertEqual(offset.tzname(None), "-1500") @patch.multiple( "imapclient.fixed_offset.time", daylight=False, timezone=-15 * 60 * 60 ) def test_for_system_no_DST(self): offset = FixedOffset.for_system() self.assertEqual(offset.tzname(None), "+1500") if __name__ == "__main__": unittest.main() imapclient-3.0.1/tests/test_folder_status.py000066400000000000000000000040651453256433100213040ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from unittest.mock import Mock from .imapclient_test import IMAPClientTest class TestFolderStatus(IMAPClientTest): def test_basic(self): self.client._imap.status.return_value = ( "OK", [b"foo (MESSAGES 3 RECENT 0 UIDNEXT 4 UIDVALIDITY 1435636895 UNSEEN 0)"], ) out = self.client.folder_status("foo") self.client._imap.status.assert_called_once_with( b'"foo"', "(MESSAGES RECENT UIDNEXT UIDVALIDITY UNSEEN)" ) self.assertDictEqual( out, { b"MESSAGES": 3, b"RECENT": 0, b"UIDNEXT": 4, b"UIDVALIDITY": 1435636895, b"UNSEEN": 0, }, ) def test_literal(self): self.client._imap.status.return_value = ( "OK", [(b"{3}", b"foo"), b" (UIDNEXT 4)"], ) out = self.client.folder_status("foo", ["UIDNEXT"]) self.client._imap.status.assert_called_once_with(b'"foo"', "(UIDNEXT)") self.assertDictEqual(out, {b"UIDNEXT": 4}) def test_extra_response(self): # In production, we've seen folder names containing spaces come back # like this and be broken into two components in the tuple. server_response = [b"My files (UIDNEXT 24369)"] mock = Mock(return_value=server_response) self.client._command_and_check = mock resp = self.client.folder_status("My files", ["UIDNEXT"]) self.assertEqual(resp, {b"UIDNEXT": 24369}) # We've also seen the response contain mailboxes we didn't # ask for. In all known cases, the desired mailbox is last. server_response = [b"sent (UIDNEXT 123)\nINBOX (UIDNEXT 24369)"] mock = Mock(return_value=server_response) self.client._command_and_check = mock resp = self.client.folder_status("INBOX", ["UIDNEXT"]) self.assertEqual(resp, {b"UIDNEXT": 24369}) imapclient-3.0.1/tests/test_imap_utf7.py000066400000000000000000000034051453256433100203160ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import unittest from imapclient.imap_utf7 import decode, encode class IMAP4UTF7TestCase(unittest.TestCase): tests = [ ["Foo", b"Foo"], ["Foo Bar", b"Foo Bar"], ["Stuff & Things", b"Stuff &- Things"], ["Hello world", b"Hello world"], ["Hello & world", b"Hello &- world"], ["Hello\xffworld", b"Hello&AP8-world"], ["\xff\xfe\xfd\xfc", b"&AP8A,gD9APw-"], [ "~peter/mail/\u65e5\u672c\u8a9e/\u53f0\u5317", b"~peter/mail/&ZeVnLIqe-/&U,BTFw-", ], # example from RFC 2060 ["\x00foo", b"&AAA-foo"], ["foo\r\n\nbar\n", b"foo&AA0ACgAK-bar&AAo-"], # see imapclient/#187 issue ] def test_encode(self): for input, output in self.tests: encoded = encode(input) self.assertIsInstance(encoded, bytes) self.assertEqual(encoded, output) def test_decode(self): for input, output in self.tests: decoded = decode(output) self.assertIsInstance(decoded, str) self.assertEqual(input, decoded) def test_printable_singletons(self): """ The IMAP4 modified UTF-7 implementation encodes all printable characters which are in ASCII using the corresponding ASCII byte. """ # All printables represent themselves for o in list(range(0x20, 0x26)) + list(range(0x27, 0x7F)): self.assertEqual(bytes((o,)), encode(chr(o))) self.assertEqual(chr(o), decode(bytes((o,)))) self.assertEqual(encode("&"), b"&-") self.assertEqual(encode("&"), b"&-") self.assertEqual(decode(b"&-"), "&") imapclient-3.0.1/tests/test_imapclient.py000066400000000000000000001143151453256433100205530ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import io import itertools import logging import socket import sys import warnings from datetime import datetime from select import POLLIN from unittest.mock import Mock, patch, sentinel from imapclient.exceptions import CapabilityError, IMAPClientError, ProtocolError from imapclient.fixed_offset import FixedOffset from imapclient.imapclient import ( _literal, _parse_quota, IMAPlibLoggerAdapter, MailboxQuotaRoots, Quota, require_capability, ) from imapclient.testable_imapclient import TestableIMAPClient as IMAPClient from .imapclient_test import IMAPClientTest class TestListFolders(IMAPClientTest): def test_list_folders(self): self.client._imap._simple_command.return_value = ("OK", [b"something"]) self.client._imap._untagged_response.return_value = ( "LIST", sentinel.folder_data, ) self.client._proc_folder_list = Mock(return_value=sentinel.folder_list) folders = self.client.list_folders("foo", "bar") self.client._imap._simple_command.assert_called_once_with( "LIST", b'"foo"', b'"bar"' ) self.assertEqual( self.client._proc_folder_list.call_args, ((sentinel.folder_data,), {}) ) self.assertTrue(folders is sentinel.folder_list) def test_list_sub_folders(self): self.client._imap._simple_command.return_value = ("OK", [b"something"]) self.client._imap._untagged_response.return_value = ( "LSUB", sentinel.folder_data, ) self.client._proc_folder_list = Mock(return_value=sentinel.folder_list) folders = self.client.list_sub_folders("foo", "bar") self.client._imap._simple_command.assert_called_once_with( "LSUB", b'"foo"', b'"bar"' ) self.assertEqual( self.client._proc_folder_list.call_args, ((sentinel.folder_data,), {}) ) self.assertTrue(folders is sentinel.folder_list) def test_list_folders_NO(self): self.client._imap._simple_command.return_value = ("NO", [b"badness"]) self.assertRaises(IMAPClientError, self.client.list_folders) def test_list_sub_folders_NO(self): self.client._imap._simple_command.return_value = ("NO", [b"badness"]) self.assertRaises(IMAPClientError, self.client.list_folders) def test_utf7_decoding(self): self.client._imap._simple_command.return_value = ("OK", [b"something"]) self.client._imap._untagged_response.return_value = ( "LIST", [ b'(\\HasNoChildren) "/" "A"', b'(\\HasNoChildren) "/" "Hello&AP8-world"', ], ) folders = self.client.list_folders("foo", "bar") self.client._imap._simple_command.assert_called_once_with( "LIST", b'"foo"', b'"bar"' ) self.assertEqual( folders, [ ((b"\\HasNoChildren",), b"/", "A"), ((b"\\HasNoChildren",), b"/", "Hello\xffworld"), ], ) def test_folder_encode_off(self): self.client.folder_encode = False self.client._imap._simple_command.return_value = ("OK", [b"something"]) self.client._imap._untagged_response.return_value = ( "LIST", [ b'(\\HasNoChildren) "/" "A"', b'(\\HasNoChildren) "/" "Hello&AP8-world"', ], ) folders = self.client.list_folders("foo", "bar") self.client._imap._simple_command.assert_called_once_with( "LIST", '"foo"', '"bar"' ) self.assertEqual( folders, [ ((b"\\HasNoChildren",), b"/", b"A"), ((b"\\HasNoChildren",), b"/", b"Hello&AP8-world"), ], ) def test_simple(self): folders = self.client._proc_folder_list( [ b'(\\HasNoChildren) "/" "A"', b'(\\HasNoChildren) "/" "Foo Bar"', ] ) self.assertEqual( folders, [ ( (b"\\HasNoChildren",), b"/", "A", ), ((b"\\HasNoChildren",), b"/", "Foo Bar"), ], ) def test_without_quotes(self): folders = self.client._proc_folder_list( [ b'(\\HasNoChildren) "/" A', b'(\\HasNoChildren) "/" B', b'(\\HasNoChildren) "/" C', ] ) self.assertEqual( folders, [ ((b"\\HasNoChildren",), b"/", "A"), ((b"\\HasNoChildren",), b"/", "B"), ((b"\\HasNoChildren",), b"/", "C"), ], ) def test_unquoted_numeric_folder_name(self): # Some IMAP implementations do this folders = self.client._proc_folder_list([b'(\\HasNoChildren) "/" 123']) self.assertEqual(folders, [((b"\\HasNoChildren",), b"/", "123")]) def test_unquoted_numeric_folder_name_parsed_as_long(self): # big enough numeric values might get parsed as longs folder_name = str(sys.maxsize + 1) folders = self.client._proc_folder_list( [b'(\\HasNoChildren) "/" ' + folder_name.encode("ascii")] ) self.assertEqual(folders, [((b"\\HasNoChildren",), b"/", folder_name)]) def test_mixed(self): folders = self.client._proc_folder_list( [ b'(\\HasNoChildren) "/" Alpha', b'(\\HasNoChildren) "/" "Foo Bar"', b'(\\HasNoChildren) "/" C', ] ) self.assertEqual( folders, [ ((b"\\HasNoChildren",), b"/", "Alpha"), ((b"\\HasNoChildren",), b"/", "Foo Bar"), ((b"\\HasNoChildren",), b"/", "C"), ], ) def test_funky_characters(self): folders = self.client._proc_folder_list( [ (b'(\\NoInferiors \\UnMarked) "/" {5}', "bang\xff"), b"", b'(\\HasNoChildren \\UnMarked) "/" "INBOX"', ] ) self.assertEqual( folders, [ ((b"\\NoInferiors", b"\\UnMarked"), b"/", "bang\xff"), ((b"\\HasNoChildren", b"\\UnMarked"), b"/", "INBOX"), ], ) def test_quoted_specials(self): folders = self.client._proc_folder_list( [ rb'(\HasNoChildren) "/" "Test \"Folder\""', rb'(\HasNoChildren) "/" "Left\"Right"', rb'(\HasNoChildren) "/" "Left\\Right"', rb'(\HasNoChildren) "/" "\"Left Right\""', rb'(\HasNoChildren) "/" "\"Left\\Right\""', ] ) self.assertEqual( folders, [ ((b"\\HasNoChildren",), b"/", 'Test "Folder"'), ((b"\\HasNoChildren",), b"/", 'Left"Right'), ((b"\\HasNoChildren",), b"/", r"Left\Right"), ((b"\\HasNoChildren",), b"/", r'"Left Right"'), ((b"\\HasNoChildren",), b"/", r'"Left\Right"'), ], ) def test_empty_response(self): self.assertEqual(self.client._proc_folder_list([None]), []) def test_blanks(self): folders = self.client._proc_folder_list( ["", None, rb'(\HasNoChildren) "/" "last"'] ) self.assertEqual(folders, [((rb"\HasNoChildren",), b"/", "last")]) class TestFindSpecialFolder(IMAPClientTest): def test_find_special_folder_with_special_use(self): self.client._cached_capabilities = (b"SPECIAL-USE",) self.client._imap._simple_command.return_value = ("OK", [b"something"]) self.client._imap._untagged_response.return_value = ( "LIST", [ b'(\\HasNoChildren) "/" "INBOX"', b'(\\HasNoChildren \\Sent) "/" "Sent"', ], ) folder = self.client.find_special_folder(b"\\Sent") self.assertEqual(folder, "Sent") def test_find_special_folder_with_special_use_single_flag(self): self.client._cached_capabilities = (b"SPECIAL-USE",) self.client._imap._simple_command.return_value = ("OK", [b"something"]) self.client._imap._untagged_response.return_value = ( "LIST", [ b'(\\HasNoChildren) "/" "INBOX"', b'(\\Sent) "/" "Sent"', ], ) folder = self.client.find_special_folder(b"\\Sent") self.assertEqual(folder, "Sent") def test_find_special_folder_without_special_use_nor_namespace(self): self.client._cached_capabilities = (b"FOO",) self.client._imap._simple_command.return_value = ("OK", [b"something"]) self.client._imap._untagged_response.return_value = ( "LIST", [ b'(\\HasNoChildren) "/" "Sent Items"', ], ) folder = self.client.find_special_folder(b"\\Sent") self.assertEqual(folder, "Sent Items") class TestSelectFolder(IMAPClientTest): def test_normal(self): self.client._command_and_check = Mock() self.client._imap.untagged_responses = { b"exists": [b"3"], b"FLAGS": [rb"(\Flagged \Deleted abc [foo]/bar def)"], b"HIGHESTMODSEQ": [b"127110"], b"OK": [ rb"[PERMANENTFLAGS (\Flagged \Deleted abc [foo]/bar def \*)] Flags permitted.", b"[UIDVALIDITY 631062293] UIDs valid.", b"[UIDNEXT 1281] Predicted next UID.", b"[HIGHESTMODSEQ 127110]", ], b"PERMANENTFLAGS": [rb"(\Flagged \Deleted abc [foo"], b"READ-WRITE": [b""], b"RECENT": [b"0"], b"UIDNEXT": [b"1281"], b"UIDVALIDITY": [b"631062293"], b"OTHER": [b"blah"], } result = self.client.select_folder(b"folder_name", sentinel.readonly) self.client._command_and_check.assert_called_once_with( "select", b'"folder_name"', sentinel.readonly ) self.maxDiff = 99999 self.assertEqual( result, { b"EXISTS": 3, b"RECENT": 0, b"UIDNEXT": 1281, b"UIDVALIDITY": 631062293, b"HIGHESTMODSEQ": 127110, b"FLAGS": (rb"\Flagged", rb"\Deleted", b"abc", b"[foo]/bar", b"def"), b"PERMANENTFLAGS": ( rb"\Flagged", rb"\Deleted", b"abc", b"[foo]/bar", b"def", rb"\*", ), b"READ-WRITE": True, b"OTHER": [b"blah"], }, ) def test_unselect(self): self.client._cached_capabilities = [b"UNSELECT"] self.client._imap._simple_command.return_value = ("OK", ["Unselect completed."]) # self.client._imap._untagged_response.return_value = ( # b'OK', [b'("name" "GImap" "vendor" "Google, Inc.")']) result = self.client.unselect_folder() self.assertEqual(result, "Unselect completed.") self.client._imap._simple_command.assert_called_with("UNSELECT") class TestAppend(IMAPClientTest): def test_without_msg_time(self): self.client._imap.append.return_value = ("OK", [b"Good"]) msg = "hi" self.client.append("foobar", msg, ["FLAG", "WAVE"], None) self.client._imap.append.assert_called_with( b'"foobar"', "(FLAG WAVE)", None, b"hi" ) @patch("imapclient.imapclient.datetime_to_INTERNALDATE") def test_with_msg_time(self, datetime_to_INTERNALDATE): datetime_to_INTERNALDATE.return_value = "somedate" self.client._imap.append.return_value = ("OK", [b"Good"]) msg = b"bye" self.client.append( "foobar", msg, ["FLAG", "WAVE"], datetime(2009, 4, 5, 11, 0, 5, 0, FixedOffset(2 * 60)), ) self.assertTrue(datetime_to_INTERNALDATE.called) self.client._imap.append.assert_called_with( b'"foobar"', "(FLAG WAVE)", '"somedate"', msg ) def test_multiappend(self): self.client._cached_capabilities = (b"MULTIAPPEND",) self.client._raw_command = Mock() self.client.multiappend("foobar", ["msg1", "msg2"]) self.client._raw_command.assert_called_once_with( b"APPEND", [b'"foobar"', b"msg1", b"msg2"], uid=False ) def test_multiappend_with_flags_and_internaldate(self): self.client._cached_capabilities = (b"MULTIAPPEND",) self.client._raw_command = Mock() self.client.multiappend( "foobar", [ { "msg": "msg1", "flags": ["FLAG", "WAVE"], "date": datetime(2009, 4, 5, 11, 0, 5, 0, FixedOffset(2 * 60)), }, { "msg": "msg2", "flags": ["FLAG", "WAVE"], }, { "msg": "msg3", "date": datetime(2009, 4, 5, 11, 0, 5, 0, FixedOffset(2 * 60)), }, ], ) self.client._raw_command.assert_called_once_with( b"APPEND", [ b'"foobar"', b"(FLAG WAVE)", b'"05-Apr-2009 11:00:05 +0200"', _literal(b"msg1"), b"(FLAG WAVE)", _literal(b"msg2"), b'"05-Apr-2009 11:00:05 +0200"', _literal(b"msg3"), ], uid=False, ) class TestAclMethods(IMAPClientTest): def setUp(self): super(TestAclMethods, self).setUp() self.client._cached_capabilities = [b"ACL"] def test_getacl(self): self.client._imap.getacl.return_value = ( "OK", [b"INBOX Fred rwipslda Sally rwip"], ) acl = self.client.getacl("INBOX") self.assertSequenceEqual(acl, [(b"Fred", b"rwipslda"), (b"Sally", b"rwip")]) def test_setacl(self): self.client._imap.setacl.return_value = ("OK", [b"SETACL done"]) response = self.client.setacl("folder", sentinel.who, sentinel.what) self.client._imap.setacl.assert_called_with( b'"folder"', sentinel.who, sentinel.what ) self.assertEqual(response, b"SETACL done") class TestQuota(IMAPClientTest): def setUp(self): super(TestQuota, self).setUp() self.client._cached_capabilities = [b"QUOTA"] def test_parse_quota(self): self.assertEqual(_parse_quota([]), []) self.assertEqual( _parse_quota([b'"User quota" (STORAGE 586720 4882812)']), [Quota("User quota", "STORAGE", 586720, 4882812)], ) self.assertEqual( _parse_quota( [ b'"User quota" (STORAGE 586720 4882812)', b'"Global quota" (MESSAGES 42 1000)', ] ), [ Quota("User quota", "STORAGE", 586720, 4882812), Quota("Global quota", "MESSAGES", 42, 1000), ], ) self.assertEqual( _parse_quota( [ b'"User quota" (STORAGE 586720 4882812 MESSAGES 42 1000)', ] ), [ Quota("User quota", "STORAGE", 586720, 4882812), Quota("User quota", "MESSAGES", 42, 1000), ], ) def test__get_quota(self): self.client._command_and_check = Mock() self.client._command_and_check.return_value = [ b'"User quota" (MESSAGES 42 1000)' ] quotas = self.client._get_quota("foo") self.client._command_and_check.assert_called_once_with("getquota", '"foo"') self.assertEqual(quotas, [Quota("User quota", "MESSAGES", 42, 1000)]) def test_set_quota(self): self.client._raw_command_untagged = Mock() self.client._raw_command_untagged.return_value = [ b'"User quota" (STORAGE 42 1000 MESSAGES 42 1000)' ] quotas = [ Quota("User quota", "STORAGE", 42, 1000), Quota("User quota", "MESSAGES", 42, 1000), ] resp = self.client.set_quota(quotas) self.client._raw_command_untagged.assert_called_once_with( b"SETQUOTA", [b'"User quota"', b"(STORAGE 1000 MESSAGES 1000)"], uid=False, response_name="QUOTA", ) self.assertListEqual(resp, quotas) def test_get_quota_root(self): self.client._raw_command_untagged = Mock() self.client._raw_command_untagged.return_value = [b'"INBOX" "User quota"'] self.client._imap.untagged_responses = dict() resp = self.client.get_quota_root("INBOX") self.client._raw_command_untagged.assert_called_once_with( b"GETQUOTAROOT", b"INBOX", uid=False, response_name="QUOTAROOT" ) expected = (MailboxQuotaRoots("INBOX", ["User quota"]), list()) self.assertTupleEqual(resp, expected) resp = self.client.get_quota("INBOX") self.assertEqual(resp, []) class TestIdleAndNoop(IMAPClientTest): def setUp(self): super(TestIdleAndNoop, self).setUp() self.client._cached_capabilities = [b"IDLE"] def assert_sock_select_calls(self, sock): self.assertListEqual( sock.method_calls, [ ("settimeout", (None,), {}), ("setblocking", (0,), {}), ("setblocking", (1,), {}), ("settimeout", (None,), {}), ], ) def assert_sock_poll_calls(self, sock): self.assertListEqual( sock.method_calls, [ ("settimeout", (None,), {}), ("setblocking", (0,), {}), ("fileno", (), {}), ("setblocking", (1,), {}), ("settimeout", (None,), {}), ], ) def test_idle(self): self.client._imap._command.return_value = sentinel.tag self.client._imap._get_response.return_value = None self.client.idle() self.client._imap._command.assert_called_with("IDLE") self.assertEqual(self.client._idle_tag, sentinel.tag) @patch("imapclient.imapclient.POLL_SUPPORT", False) @patch("imapclient.imapclient.select.select") def test_idle_check_blocking(self, mock_select): mock_sock = Mock() self.client._imap.sock = self.client._imap.sslobj = mock_sock mock_select.return_value = ([True], [], []) counter = itertools.count() def fake_get_line(): count = next(counter) if count == 0: return b"* 1 EXISTS" elif count == 1: return b"* 0 EXPUNGE" else: raise socket.timeout self.client._imap._get_line = fake_get_line responses = self.client.idle_check() mock_select.assert_called_once_with([mock_sock], [], [], None) self.assert_sock_select_calls(mock_sock) self.assertListEqual([(1, b"EXISTS"), (0, b"EXPUNGE")], responses) @patch("imapclient.imapclient.POLL_SUPPORT", False) @patch("imapclient.imapclient.select.select") def test_idle_check_timeout(self, mock_select): mock_sock = Mock() self.client._imap.sock = self.client._imap.sslobj = mock_sock mock_select.return_value = ([], [], []) responses = self.client.idle_check(timeout=0.5) mock_select.assert_called_once_with([mock_sock], [], [], 0.5) self.assert_sock_select_calls(mock_sock) self.assertListEqual([], responses) @patch("imapclient.imapclient.POLL_SUPPORT", False) @patch("imapclient.imapclient.select.select") def test_idle_check_with_data(self, mock_select): mock_sock = Mock() self.client._imap.sock = self.client._imap.sslobj = mock_sock mock_select.return_value = ([True], [], []) counter = itertools.count() def fake_get_line(): count = next(counter) if count == 0: return b"* 99 EXISTS" else: raise socket.timeout self.client._imap._get_line = fake_get_line responses = self.client.idle_check() mock_select.assert_called_once_with([mock_sock], [], [], None) self.assert_sock_select_calls(mock_sock) self.assertListEqual([(99, b"EXISTS")], responses) @patch("imapclient.imapclient.POLL_SUPPORT", True) @patch("imapclient.imapclient.select.poll") def test_idle_check_blocking_poll(self, mock_poll_module): mock_sock = Mock(fileno=Mock(return_value=1)) self.client._imap.sock = self.client._imap.sslobj = mock_sock mock_poller = Mock(poll=Mock(return_value=[(1, POLLIN)])) mock_poll_module.return_value = mock_poller counter = itertools.count() def fake_get_line(): count = next(counter) if count == 0: return b"* 1 EXISTS" elif count == 1: return b"* 0 EXPUNGE" else: raise socket.timeout self.client._imap._get_line = fake_get_line responses = self.client.idle_check() assert mock_poll_module.call_count == 1 mock_poller.register.assert_called_once_with(1, POLLIN) mock_poller.poll.assert_called_once_with(None) self.assert_sock_poll_calls(mock_sock) self.assertListEqual([(1, b"EXISTS"), (0, b"EXPUNGE")], responses) @patch("imapclient.imapclient.POLL_SUPPORT", True) @patch("imapclient.imapclient.select.poll") def test_idle_check_timeout_poll(self, mock_poll_module): mock_sock = Mock(fileno=Mock(return_value=1)) self.client._imap.sock = self.client._imap.sslobj = mock_sock mock_poller = Mock(poll=Mock(return_value=[])) mock_poll_module.return_value = mock_poller responses = self.client.idle_check(timeout=0.5) assert mock_poll_module.call_count == 1 mock_poller.register.assert_called_once_with(1, POLLIN) mock_poller.poll.assert_called_once_with(500) self.assert_sock_poll_calls(mock_sock) self.assertListEqual([], responses) @patch("imapclient.imapclient.POLL_SUPPORT", True) @patch("imapclient.imapclient.select.poll") def test_idle_check_with_data_poll(self, mock_poll_module): mock_sock = Mock(fileno=Mock(return_value=1)) self.client._imap.sock = self.client._imap.sslobj = mock_sock mock_poller = Mock(poll=Mock(return_value=[(1, POLLIN)])) mock_poll_module.return_value = mock_poller counter = itertools.count() def fake_get_line(): count = next(counter) if count == 0: return b"* 99 EXISTS" else: raise socket.timeout self.client._imap._get_line = fake_get_line responses = self.client.idle_check() assert mock_poll_module.call_count == 1 mock_poller.register.assert_called_once_with(1, POLLIN) mock_poller.poll.assert_called_once_with(None) self.assert_sock_poll_calls(mock_sock) self.assertListEqual([(99, b"EXISTS")], responses) def test_idle_done(self): self.client._idle_tag = sentinel.tag mockSend = Mock() self.client._imap.send = mockSend mockConsume = Mock(return_value=sentinel.out) self.client._consume_until_tagged_response = mockConsume result = self.client.idle_done() mockSend.assert_called_with(b"DONE\r\n") mockConsume.assert_called_with(sentinel.tag, "IDLE") self.assertEqual(result, sentinel.out) def test_noop(self): mockCommand = Mock(return_value=sentinel.tag) self.client._imap._command = mockCommand mockConsume = Mock(return_value=sentinel.out) self.client._consume_until_tagged_response = mockConsume result = self.client.noop() mockCommand.assert_called_with("NOOP") mockConsume.assert_called_with(sentinel.tag, "NOOP") self.assertEqual(result, sentinel.out) def test_consume_until_tagged_response(self): client = self.client client._imap.tagged_commands = {sentinel.tag: None} counter = itertools.count() def fake_get_response(): count = next(counter) if count == 0: return b"* 99 EXISTS" client._imap.tagged_commands[sentinel.tag] = ("OK", [b"Idle done"]) client._imap._get_response = fake_get_response text, responses = client._consume_until_tagged_response(sentinel.tag, b"IDLE") self.assertEqual(client._imap.tagged_commands, {}) self.assertEqual(text, b"Idle done") self.assertListEqual([(99, b"EXISTS")], responses) class TestDebugLogging(IMAPClientTest): def test_IMAP_is_patched(self): # Remove all logging handlers so that the order of tests does not # prevent basicConfig from being executed for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) log_stream = io.StringIO() logging.basicConfig(stream=log_stream, level=logging.DEBUG) self.client._imap._mesg("two") self.assertIn("DEBUG:imapclient.imaplib:two", log_stream.getvalue()) def test_redacted_password(self): logger_mock = Mock() logger_mock.manager.disable = logging.DEBUG logger_mock.getEffectiveLevel.return_value = logging.DEBUG adapter = IMAPlibLoggerAdapter(logger_mock, dict()) adapter.info("""> b'ICHH1 LOGIN foo@bar.org "secret"'""") if sys.version_info >= (3, 6, 4): # LoggerAdapter in Python 3.6.4+ calls logger.log() logger_mock.log.assert_called_once_with( logging.INFO, "> b'ICHH1 LOGIN **REDACTED**", extra={} ) else: # LoggerAdapter in Python 3.4 to 3.6 calls logger._log() logger_mock._log.assert_called_once_with( logging.INFO, "> b'ICHH1 LOGIN **REDACTED**", (), extra={} ) class TestTimeNormalisation(IMAPClientTest): def test_default(self): self.assertTrue(self.client.normalise_times) @patch("imapclient.imapclient.parse_fetch_response") def test_pass_through(self, parse_fetch_response): self.client._imap._command_complete.return_value = ("OK", sentinel.data) self.client._imap._untagged_response.return_value = ("OK", sentinel.fetch_data) self.client.use_uid = sentinel.use_uid def check(expected): self.client.fetch(22, ["SOMETHING"]) parse_fetch_response.assert_called_with( sentinel.fetch_data, expected, sentinel.use_uid ) self.client.normalise_times = True check(True) self.client.normalise_times = False check(False) class TestNamespace(IMAPClientTest): def setUp(self): super(TestNamespace, self).setUp() self.client._cached_capabilities = [b"NAMESPACE"] def set_return(self, value): self.client._imap.namespace.return_value = ("OK", [value]) def test_simple(self): self.set_return(b'(("FOO." "/")) NIL NIL') self.assertEqual(self.client.namespace(), ((("FOO.", "/"),), None, None)) def test_folder_decoding(self): self.set_return(b'(("&AP8-." "/")) NIL NIL') self.assertEqual(self.client.namespace(), ((("\xff.", "/"),), None, None)) def test_without_folder_decoding(self): self.set_return(b'(("&AP8-." "/")) NIL NIL') self.client.folder_encode = False self.assertEqual(self.client.namespace(), (((b"&AP8-.", "/"),), None, None)) def test_other_only(self): self.set_return(b'NIL NIL (("" "."))') self.assertEqual(self.client.namespace(), (None, None, (("", "."),))) def test_complex(self): self.set_return( b'(("" "/")) ' b'(("~" "/")) ' b'(("#shared/" "/") ("#public/" "/")("#ftp/" "/")("#news." "."))' ) self.assertEqual( self.client.namespace(), ( (("", "/"),), (("~", "/"),), (("#shared/", "/"), ("#public/", "/"), ("#ftp/", "/"), ("#news.", ".")), ), ) class TestCapabilities(IMAPClientTest): def test_preauth(self): self.client._imap.capabilities = ("FOO", "BAR") self.client._imap.untagged_responses = {} self.assertEqual(self.client.capabilities(), (b"FOO", b"BAR")) def test_server_returned_capability_after_auth(self): self.client._imap.capabilities = (b"FOO",) self.client._imap.untagged_responses = {"CAPABILITY": [b"FOO MORE"]} self.assertEqual(self.client._cached_capabilities, None) self.assertEqual(self.client.capabilities(), (b"FOO", b"MORE")) self.assertEqual(self.client._cached_capabilities, (b"FOO", b"MORE")) self.assertEqual(self.client._imap.untagged_responses, {}) def test_caching(self): self.client._imap.capabilities = ("FOO",) self.client._imap.untagged_responses = {} self.client._cached_capabilities = (b"FOO", b"MORE") self.assertEqual(self.client.capabilities(), (b"FOO", b"MORE")) def test_post_auth_request(self): self.client._imap.capabilities = ("FOO",) self.client._imap.untagged_responses = {} self.client._imap.state = "SELECTED" self.client._imap.capability.return_value = ("OK", [b"FOO BAR"]) self.assertEqual(self.client.capabilities(), (b"FOO", b"BAR")) self.assertEqual(self.client._cached_capabilities, (b"FOO", b"BAR")) def test_with_starttls(self): # Initial connection self.client._imap.capabilities = ("FOO",) self.client._imap.untagged_responses = {} self.client._imap.state = "NONAUTH" self.assertEqual(self.client.capabilities(), (b"FOO",)) # Now do STARTTLS; capabilities change and should be reported. self.client._starttls_done = True self.client._imap.capability.return_value = ("OK", [b"FOO BAR"]) self.assertEqual(self.client.capabilities(), (b"FOO", b"BAR")) # Login done; capabilities change again. self.client._imap.state = "AUTH" self.client._imap.capability.return_value = ("OK", [b"FOO BAR QUX"]) self.assertEqual(self.client.capabilities(), (b"FOO", b"BAR", b"QUX")) def test_has_capability(self): self.client._cached_capabilities = (b"FOO", b"MORE") self.assertTrue(self.client.has_capability(b"FOO")) self.assertTrue(self.client.has_capability(b"foo")) self.assertFalse(self.client.has_capability(b"BAR")) self.assertTrue(self.client.has_capability("FOO")) self.assertTrue(self.client.has_capability("foo")) self.assertFalse(self.client.has_capability("BAR")) def test_decorator(self): class Foo(object): def has_capability(self, capability): if capability == "TRUE": return True return False @require_capability("TRUE") def yes(self): return True @require_capability("FALSE") def no(self): return False foo = Foo() self.assertTrue(foo.yes()) self.assertRaises(CapabilityError, foo.no) class TestId(IMAPClientTest): def setUp(self): super(TestId, self).setUp() self.client._cached_capabilities = [b"ID"] def test_id(self): self.client._imap._simple_command.return_value = ("OK", [b"Success"]) self.client._imap._untagged_response.return_value = ( b"OK", [b'("name" "GImap" "vendor" "Google, Inc.")'], ) id_response = self.client.id_({"name": "IMAPClient"}) self.client._imap._simple_command.assert_called_with( "ID", '("name" "IMAPClient")' ) self.assertSequenceEqual( id_response, ((b"name", b"GImap", b"vendor", b"Google, Inc."),) ) def test_no_support(self): self.client._cached_capabilities = (b"IMAP4rev1",) self.assertRaises(CapabilityError, self.client.id_) def test_invalid_parameters(self): self.assertRaises(TypeError, self.client.id_, "bananarama") class TestRawCommand(IMAPClientTest): def setUp(self): super(TestRawCommand, self).setUp() self.client._imap._get_response.return_value = None self.client._imap._command_complete.return_value = ("OK", ["done"]) self.client._cached_capabilities = () def check(self, command, args, expected): typ, data = self.client._raw_command(command, args) self.assertEqual(typ, "OK") self.assertEqual(data, ["done"]) self.assertEqual(self.client._imap.sent, expected) def test_plain(self): self.check( b"search", [b"ALL"], b"tag UID SEARCH ALL\r\n", ) def test_not_uid(self): self.client.use_uid = False self.check( b"search", [b"ALL"], b"tag SEARCH ALL\r\n", ) def test_literal_at_end(self): self.check( b"search", [b"TEXT", b"\xfe\xff"], b"tag UID SEARCH TEXT {2}\r\n" b"\xfe\xff\r\n", ) def test_embedded_literal(self): self.check( b"search", [b"TEXT", b"\xfe\xff", b"DELETED"], b"tag UID SEARCH TEXT {2}\r\n" b"\xfe\xff DELETED\r\n", ) def test_multiple_literals(self): self.check( b"search", [b"TEXT", b"\xfe\xff", b"TEXT", b"\xcc"], b"tag UID SEARCH TEXT {2}\r\n" b"\xfe\xff TEXT {1}\r\n" b"\xcc\r\n", ) def test_literal_plus(self): self.client._cached_capabilities = (b"LITERAL+",) typ, data = self.client._raw_command( b"APPEND", [b"\xff", _literal(b"hello")], uid=False ) self.assertEqual(typ, "OK") self.assertEqual(data, ["done"]) self.assertEqual( self.client._imap.sent, b"tag APPEND {1+}\r\n" b"\xff {5+}\r\n" b"hello\r\n", ) def test_literal_plus_multiple_literals(self): self.client._cached_capabilities = (b"LITERAL+",) typ, data = self.client._raw_command( b"APPEND", [b"\xff", _literal(b"hello"), b"TEXT", _literal(b"test")], uid=False, ) self.assertEqual(typ, "OK") self.assertEqual(data, ["done"]) self.assertEqual( self.client._imap.sent, b"tag APPEND {1+}\r\n" b"\xff {5+}\r\n" b"hello" b" TEXT {4+}\r\n" b"test\r\n", ) def test_complex(self): self.check( b"search", [b"FLAGGED", b"TEXT", b"\xfe\xff", b"TEXT", b"\xcc", b"TEXT", b"foo"], b"tag UID SEARCH FLAGGED TEXT {2}\r\n" b"\xfe\xff TEXT {1}\r\n" b"\xcc TEXT foo\r\n", ) def test_invalid_input_type(self): self.assertRaises(ValueError, self.client._raw_command, "foo", []) self.assertRaises(ValueError, self.client._raw_command, "foo", ["foo"]) def test_failed_continuation_wait(self): self.client._imap._get_response.return_value = b"blah" self.client._imap.tagged_commands["tag"] = ("NO", ["go away"]) expected_error = r"unexpected response while waiting for continuation response: \(u?'NO', \[u?'go away'\]\)" with self.assertRaisesRegex(IMAPClient.AbortError, expected_error): self.client._raw_command(b"FOO", [b"\xff"]) class TestExpunge(IMAPClientTest): def test_expunge(self): mockCommand = Mock(return_value=sentinel.tag) mockConsume = Mock(return_value=sentinel.out) self.client._imap._command = mockCommand self.client._consume_until_tagged_response = mockConsume result = self.client.expunge() mockCommand.assert_called_with("EXPUNGE") mockConsume.assert_called_with(sentinel.tag, "EXPUNGE") self.assertEqual(sentinel.out, result) def test_id_expunge(self): self.client._imap.uid.return_value = ("OK", [None]) self.assertEqual([None], self.client.expunge(["4", "5", "6"])) class TestShutdown(IMAPClientTest): def test_shutdown(self): self.client.shutdown() self.client._imap.shutdown.assert_called_once_with() class TestContextManager(IMAPClientTest): def test_context_manager(self): with self.client as client: self.assertIsInstance(client, IMAPClient) self.client._imap.logout.assert_called_once_with() @patch("imapclient.imapclient.logger") def test_context_manager_fail_closing(self, mock_logger): self.client._imap.logout.side_effect = RuntimeError("Error logout") self.client._imap.shutdown.side_effect = RuntimeError("Error shutdown") with self.client as client: self.assertIsInstance(client, IMAPClient) self.client._imap.logout.assert_called_once_with() self.client._imap.shutdown.assert_called_once_with() mock_logger.info.assert_called_once_with( "Could not close the connection cleanly: %s", self.client._imap.shutdown.side_effect, ) def test_exception_inside_context_manager(self): with self.assertRaises(ValueError): with self.client as _: raise ValueError("Error raised inside the context manager") class TestProtocolError(IMAPClientTest): def test_tagged_response_with_parse_error(self): client = self.client client._imap.tagged_commands = {sentinel.tag: None} client._imap._get_response = lambda: b"NOT-A-STAR 99 EXISTS" with self.assertRaises(ProtocolError): client._consume_until_tagged_response(sentinel.tag, b"IDLE") class TestSocket(IMAPClientTest): def test_issues_warning_for_deprecating_sock_property(self): mock_sock = Mock() self.client._imap.sock = self.client._imap.sslobj = mock_sock with warnings.catch_warnings(record=True) as warnings_caught: warnings.simplefilter("always", DeprecationWarning) assert self.client._sock == self.client.socket() assert len(warnings_caught) == 1 imapclient-3.0.1/tests/test_init.py000066400000000000000000000070051453256433100173660ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import unittest from unittest.mock import Mock, patch, sentinel from imapclient.imapclient import IMAPClient, SocketTimeout class TestInit(unittest.TestCase): def setUp(self): patcher = patch("imapclient.imapclient.imap4") self.imap4 = patcher.start() self.addCleanup(patcher.stop) patcher = patch("imapclient.imapclient.tls") self.tls = patcher.start() self.addCleanup(patcher.stop) patcher = patch("imapclient.imapclient.imaplib") self.imaplib = patcher.start() self.addCleanup(patcher.stop) def test_plain(self): fakeIMAP4 = Mock() self.imap4.IMAP4WithTimeout.return_value = fakeIMAP4 imap = IMAPClient("1.2.3.4", ssl=False, timeout=sentinel.timeout) self.assertEqual(imap._imap, fakeIMAP4) self.imap4.IMAP4WithTimeout.assert_called_with("1.2.3.4", 143, sentinel.timeout) self.assertEqual(imap.host, "1.2.3.4") self.assertEqual(imap.port, 143) self.assertEqual(imap.ssl, False) self.assertEqual(imap.ssl_context, None) self.assertEqual(imap.stream, False) def test_plain_SocketTimeout(self): fakeIMAP4 = Mock() self.imap4.IMAP4WithTimeout.return_value = fakeIMAP4 imap = IMAPClient( "1.2.3.4", ssl=False, timeout=SocketTimeout(sentinel.connect_timeout, sentinel.timeout), ) self.assertEqual(imap._imap, fakeIMAP4) self.imap4.IMAP4WithTimeout.assert_called_with( "1.2.3.4", 143, sentinel.connect_timeout ) def test_SSL(self): fakeIMAP4_TLS = Mock() self.tls.IMAP4_TLS.return_value = fakeIMAP4_TLS imap = IMAPClient( "1.2.3.4", ssl_context=sentinel.context, timeout=sentinel.timeout ) self.assertEqual(imap._imap, fakeIMAP4_TLS) self.tls.IMAP4_TLS.assert_called_with( "1.2.3.4", 993, sentinel.context, sentinel.timeout ) self.assertEqual(imap.host, "1.2.3.4") self.assertEqual(imap.port, 993) self.assertEqual(imap.ssl, True) self.assertEqual(imap.ssl_context, sentinel.context) self.assertEqual(imap.stream, False) def test_SSL_SocketTimeout(self): fakeIMAP4_TLS = Mock() self.tls.IMAP4_TLS.return_value = fakeIMAP4_TLS imap = IMAPClient( "1.2.3.4", ssl_context=sentinel.context, timeout=SocketTimeout(sentinel.connect_timeout, sentinel.timeout), ) self.assertEqual(imap._imap, fakeIMAP4_TLS) self.tls.IMAP4_TLS.assert_called_with( "1.2.3.4", 993, sentinel.context, sentinel.connect_timeout ) def test_stream(self): fakeIMAP4_stream = Mock() self.imaplib.IMAP4_stream.return_value = fakeIMAP4_stream imap = IMAPClient("command", stream=True, ssl=False) self.assertEqual(imap._imap, fakeIMAP4_stream) self.imaplib.IMAP4_stream.assert_called_with("command") self.assertEqual(imap.host, "command") self.assertEqual(imap.port, None) self.assertEqual(imap.ssl, False) self.assertEqual(imap.stream, True) def test_ssl_and_stream_is_error(self): self.assertRaises(ValueError, IMAPClient, "command", ssl=True, stream=True) def test_stream_and_port_is_error(self): self.assertRaises(ValueError, IMAPClient, "command", stream=True, port=123) imapclient-3.0.1/tests/test_response_lexer.py000066400000000000000000000067171453256433100214710ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import unittest from imapclient.response_lexer import TokenSource class TestTokenSource(unittest.TestCase): def test_one_token(self): self.check([b"abc"], [b"abc"]) def test_simple_tokens(self): self.check([b"abc 111 def"], [b"abc", b"111", b"def"]) def test_multiple_inputs(self): self.check([b"abc 111", b"def 222"], [b"abc", b"111", b"def", b"222"]) def test_whitespace(self): self.check([b"abc def"], [b"abc", b"def"]) self.check([b" abc \t\t\r\r\n\n def "], [b"abc", b"def"]) def test_quoted_strings(self): self.check([b'"abc def"'], [b'"abc def"']) self.check([b'""'], [b'""']) self.check([b'111 "abc def" 222'], [b"111", b'"abc def"', b"222"]) def test_unterminated_strings(self): message = "No closing '\"'" self.check_error([b'"'], message) self.check_error([b'"aaa bbb'], message) def test_escaping(self): self.check([rb'"aaa\"bbb"'], [rb'"aaa"bbb"']) self.check([rb'"aaa\\bbb"'], [rb'"aaa\bbb"']) self.check([rb'"aaa\\bbb \"\""'], [rb'"aaa\bbb """']) def test_invalid_escape(self): self.check([rb'"aaa\Zbbb"'], [rb'"aaa\Zbbb"']) def test_lists(self): self.check([b"()"], [b"(", b")"]) self.check([b"(aaa)"], [b"(", b"aaa", b")"]) self.check( [b'(aaa "bbb def" 123)'], [b"(", b"aaa", b'"bbb def"', b"123", b")"] ) self.check( [b"(aaa)(bbb ccc)"], [b"(", b"aaa", b")", b"(", b"bbb", b"ccc", b")"] ) self.check( [b"(aaa (bbb ccc))"], [b"(", b"aaa", b"(", b"bbb", b"ccc", b")", b")"] ) def test_square_brackets(self): self.check([b"[aaa bbb]"], [b"[aaa bbb]"]) self.check([b"aaa[bbb]"], [b"aaa[bbb]"]) self.check([b"[bbb]aaa"], [b"[bbb]aaa"]) self.check([b"aaa [bbb]"], [b"aaa", b"[bbb]"]) def test_no_escaping_in_square_brackets(self): self.check([rb"[aaa\\bbb]"], [rb"[aaa\\bbb]"]) def test_unmatched_square_brackets(self): message = "No closing ']'" self.check_error([b"["], message) self.check_error([b"[aaa bbb"], message) def test_literal(self): source = TokenSource([(b"abc {7}", b"foo bar"), b")"]) tokens = iter(source) self.assertEqual(next(tokens), b"abc") self.assertEqual(next(tokens), b"{7}") self.assertEqual(source.current_literal, b"foo bar") self.assertEqual(next(tokens), b")") self.assertRaises(StopIteration, lambda: next(tokens)) def test_literals(self): source = TokenSource([(b"abc {7}", b"foo bar"), (b"{5}", b"snafu"), b")"]) tokens = iter(source) self.assertEqual(next(tokens), b"abc") self.assertEqual(next(tokens), b"{7}") self.assertEqual(source.current_literal, b"foo bar") self.assertEqual(next(tokens), b"{5}") self.assertEqual(source.current_literal, b"snafu") self.assertEqual(next(tokens), b")") self.assertRaises(StopIteration, lambda: next(tokens)) def check(self, text_in, expected_out): tokens = TokenSource(text_in) self.assertSequenceEqual(list(tokens), expected_out) def check_error(self, text_in, expected_message): self.assertRaisesRegex( ValueError, expected_message, lambda: list(TokenSource(text_in)) ) imapclient-3.0.1/tests/test_response_parser.py000066400000000000000000000600231453256433100216340ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses """ Unit tests for the FetchTokeniser and FetchParser classes """ import unittest from datetime import datetime from unittest.mock import patch from imapclient.datetime_util import datetime_to_native from imapclient.exceptions import ProtocolError from imapclient.fixed_offset import FixedOffset from imapclient.response_parser import ( parse_fetch_response, parse_message_list, parse_response, ) from imapclient.response_types import Address, Envelope # TODO: test invalid dates and times CRLF = b"\r\n" class TestParseResponse(unittest.TestCase): def test_unquoted(self): self._test(b"FOO", b"FOO") self._test(b"F.O:-O_0;", b"F.O:-O_0;") self._test(rb"\Seen", rb"\Seen") def test_string(self): self._test(b'"TEST"', b"TEST") def test_int(self): self._test(b"45", 45) def test_int_zero(self): self._test(b"0", 0) def test_not_an_int(self): self._test(b"0123", b"0123") def test_nil(self): self._test(b"NIL", None) def test_empty_tuple(self): self._test(b"()", ()) def test_tuple(self): self._test(b'(123 "foo" GeE)', (123, b"foo", b"GeE")) def test_int_and_tuple(self): self._test(b'1 (123 "foo")', (1, (123, b"foo")), wrap=False) def test_nested_tuple(self): self._test(b'(123 "foo" ("more" NIL) 66)', (123, b"foo", (b"more", None), 66)) def test_deeper_nest_tuple(self): self._test( b'(123 "foo" ((0 1 2) "more" NIL) 66)', (123, b"foo", ((0, 1, 2), b"more", None), 66), ) def test_complex_mixed(self): self._test( b'((FOO "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 1152 23) ' b'("TEXT" "PLAIN" ("CHARSET" "US-ASCII" "NAME" "cc.diff") ' b'"" "foo" "BASE64" 4554 73) "MIXED")', ( ( b"FOO", b"PLAIN", (b"CHARSET", b"US-ASCII"), None, None, b"7BIT", 1152, 23, ), ( b"TEXT", b"PLAIN", (b"CHARSET", b"US-ASCII", b"NAME", b"cc.diff"), b"", b"foo", b"BASE64", 4554, 73, ), b"MIXED", ), ) def test_envelopey(self): self._test( b'(UID 5 ENVELOPE ("internal_date" "subject" ' b'(("name" NIL "address1" "domain1.com")) ' b'((NIL NIL "address2" "domain2.com")) ' b'(("name" NIL "address3" "domain3.com")) ' b'((NIL NIL "address4" "domain4.com")) ' b'NIL NIL "" ""))', ( b"UID", 5, b"ENVELOPE", ( b"internal_date", b"subject", ((b"name", None, b"address1", b"domain1.com"),), ((None, None, b"address2", b"domain2.com"),), ((b"name", None, b"address3", b"domain3.com"),), ((None, None, b"address4", b"domain4.com"),), None, None, b"", b"", ), ), ) def test_envelopey_quoted(self): self._test( b'(UID 5 ENVELOPE ("internal_date" "subject with \\"quotes\\"" ' b'(("name" NIL "address1" "domain1.com")) ' b'((NIL NIL "address2" "domain2.com")) ' b'(("name" NIL "address3" "domain3.com")) ' b'((NIL NIL "address4" "domain4.com")) ' b'NIL NIL "" ""))', ( b"UID", 5, b"ENVELOPE", ( b"internal_date", b'subject with "quotes"', ((b"name", None, b"address1", b"domain1.com"),), ((None, None, b"address2", b"domain2.com"),), ((b"name", None, b"address3", b"domain3.com"),), ((None, None, b"address4", b"domain4.com"),), None, None, b"", b"", ), ), ) def test_literal(self): literal_text = add_crlf(b"012\n" b"abc def XYZ\n") self._test([(b"{18}", literal_text)], literal_text) def test_literal_with_more(self): literal_text = add_crlf(b"012\n" b"abc def XYZ\n") response = [(b'(12 "foo" {18}', literal_text), b")"] self._test(response, (12, b"foo", literal_text)) def test_quoted_specials(self): self._test(rb'"\"foo bar\""', b'"foo bar"') self._test(rb'"foo \"bar\""', b'foo "bar"') self._test(rb'"foo\\bar"', rb"foo\bar") def test_square_brackets(self): self._test(b"foo[bar rrr]", b"foo[bar rrr]") self._test(b'"foo[bar rrr]"', b"foo[bar rrr]") self._test(b"[foo bar]def", b"[foo bar]def") self._test(b"(foo [bar rrr])", (b"foo", b"[bar rrr]")) self._test(b"(foo foo[bar rrr])", (b"foo", b"foo[bar rrr]")) def test_incomplete_tuple(self): self._test_parse_error(b"abc (1 2", r'Tuple incomplete before "\(1 2"') def test_bad_literal(self): self._test_parse_error( [(b"{99}", b"abc")], "Expecting literal of size 99, got 3" ) def test_bad_quoting(self): self._test_parse_error(b'"abc next', """No closing '"'""") def _test(self, to_parse, expected, wrap=True): if wrap: # convenience - expected value should be wrapped in another tuple expected = (expected,) if not isinstance(to_parse, list): to_parse = [to_parse] output = parse_response(to_parse) self.assertSequenceEqual(output, expected) def _test_parse_error(self, to_parse, expected_msg): if not isinstance(to_parse, list): to_parse = [to_parse] self.assertRaisesRegex(ProtocolError, expected_msg, parse_response, to_parse) class TestParseMessageList(unittest.TestCase): def test_basic(self): out = parse_message_list([b"1 2 3"]) self.assertSequenceEqual(out, [1, 2, 3]) self.assertEqual(out.modseq, None) def test_one_id(self): self.assertSequenceEqual(parse_message_list([b"4"]), [4]) def test_modseq(self): out = parse_message_list([b"1 2 3 (modseq 999)"]) self.assertSequenceEqual(out, [1, 2, 3]) self.assertEqual(out.modseq, 999) def test_modseq_no_space(self): out = parse_message_list([b"1 2 3(modseq 999)"]) self.assertSequenceEqual(out, [1, 2, 3]) self.assertEqual(out.modseq, 999) def test_modseq_interleaved(self): # Unlikely but test it anyway. out = parse_message_list([b"1 2 (modseq 9) 3 4"]) self.assertSequenceEqual(out, [1, 2, 3, 4]) self.assertEqual(out.modseq, 9) class TestParseFetchResponse(unittest.TestCase): def test_basic(self): self.assertEqual(parse_fetch_response([b"4 ()"]), {4: {b"SEQ": 4}}) def test_none_special_case(self): self.assertEqual(parse_fetch_response([None]), {}) def test_bad_msgid(self): self.assertRaises(ProtocolError, parse_fetch_response, [b"abc ()"]) def test_bad_data(self): self.assertRaises(ProtocolError, parse_fetch_response, [b"2 WHAT"]) def test_missing_data(self): self.assertRaises(ProtocolError, parse_fetch_response, [b"2"]) def test_simple_pairs(self): self.assertEqual( parse_fetch_response([b'23 (ABC 123 StUfF "hello")']), {23: {b"ABC": 123, b"STUFF": b"hello", b"SEQ": 23}}, ) def test_odd_pairs(self): self.assertRaises(ProtocolError, parse_fetch_response, [b"(ONE)"]) self.assertRaises(ProtocolError, parse_fetch_response, [b"(ONE TWO THREE)"]) def test_UID(self): self.assertEqual(parse_fetch_response([b"23 (UID 76)"]), {76: {b"SEQ": 23}}) self.assertEqual(parse_fetch_response([b"23 (uiD 76)"]), {76: {b"SEQ": 23}}) def test_not_uid_is_key(self): self.assertEqual( parse_fetch_response([b"23 (UID 76)"], uid_is_key=False), {23: {b"UID": 76, b"SEQ": 23}}, ) def test_bad_UID(self): self.assertRaises(ProtocolError, parse_fetch_response, [b"(UID X)"]) def test_FLAGS(self): self.assertEqual( parse_fetch_response([rb"23 (FLAGS (\Seen Stuff))"]), {23: {b"SEQ": 23, b"FLAGS": (rb"\Seen", b"Stuff")}}, ) def test_multiple_messages(self): self.assertEqual( parse_fetch_response([b"2 (FLAGS (Foo Bar)) ", b"7 (FLAGS (Baz Sneeve))"]), { 2: {b"FLAGS": (b"Foo", b"Bar"), b"SEQ": 2}, 7: {b"FLAGS": (b"Baz", b"Sneeve"), b"SEQ": 7}, }, ) def test_same_message_appearing_multiple_times(self): # This can occur when server sends unsolicited FETCH responses # (e.g. RFC 4551) self.assertEqual( parse_fetch_response([b"2 (FLAGS (Foo Bar)) ", b"2 (MODSEQ 4)"]), {2: {b"FLAGS": (b"Foo", b"Bar"), b"SEQ": 2, b"MODSEQ": 4}}, ) def test_literals(self): self.assertEqual( parse_fetch_response( [ (b"1 (RFC822.TEXT {4}", b"body"), (b" RFC822 {21}", b"Subject: test\r\n\r\nbody"), b")", ] ), { 1: { b"RFC822.TEXT": b"body", b"RFC822": b"Subject: test\r\n\r\nbody", b"SEQ": 1, } }, ) def test_literals_and_keys_with_square_brackets(self): self.assertEqual( parse_fetch_response([(b"1 (BODY[TEXT] {11}", b"Hi there.\r\n"), b")"]), {1: {b"BODY[TEXT]": b"Hi there.\r\n", b"SEQ": 1}}, ) def test_BODY_HEADER_FIELDS(self): header_text = b"Subject: A subject\r\nFrom: Some one \r\n\r\n" self.assertEqual( parse_fetch_response( [ ( b"123 (UID 31710 BODY[HEADER.FIELDS (from subject)] {57}", header_text, ), b")", ] ), {31710: {b"BODY[HEADER.FIELDS (FROM SUBJECT)]": header_text, b"SEQ": 123}}, ) def test_BODY(self): self.check_BODYish_single_part(b"BODY") self.check_BODYish_multipart(b"BODY") self.check_BODYish_nested_multipart(b"BODY") def test_BODYSTRUCTURE(self): self.check_BODYish_single_part(b"BODYSTRUCTURE") self.check_BODYish_nested_multipart(b"BODYSTRUCTURE") def check_BODYish_single_part(self, respType): text = ( b"123 (UID 317 " + respType + b'("TEXT" "PLAIN" ("CHARSET" "us-ascii") NIL NIL "7BIT" 16 1))' ) parsed = parse_fetch_response([text]) self.assertEqual( parsed, { 317: { respType: ( b"TEXT", b"PLAIN", (b"CHARSET", b"us-ascii"), None, None, b"7BIT", 16, 1, ), b"SEQ": 123, } }, ) self.assertFalse(parsed[317][respType].is_multipart) def check_BODYish_multipart(self, respType): text = ( b"123 (UID 269 " + respType + b" " b'(("TEXT" "HTML" ("CHARSET" "us-ascii") NIL NIL "QUOTED-PRINTABLE" 55 3)' b'("TEXT" "PLAIN" ("CHARSET" "us-ascii") NIL NIL "7BIT" 26 1) "MIXED"))' ) parsed = parse_fetch_response([text]) self.assertEqual( parsed, { 269: { respType: ( [ ( b"TEXT", b"HTML", (b"CHARSET", b"us-ascii"), None, None, b"QUOTED-PRINTABLE", 55, 3, ), ( b"TEXT", b"PLAIN", (b"CHARSET", b"us-ascii"), None, None, b"7BIT", 26, 1, ), ], b"MIXED", ), b"SEQ": 123, } }, ) self.assertTrue(parsed[269][respType].is_multipart) def check_BODYish_nested_multipart(self, respType): text = ( b"1 (" + respType + b"(" b"(" b'("text" "html" ("charset" "utf-8") NIL NIL "7bit" 97 3 NIL NIL NIL NIL)' b'("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 62 3 NIL NIL NIL NIL)' b'"alternative" ("boundary" "===============8211050864078048428==") NIL NIL NIL' b")" b'("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 16 1 NIL ("attachment" ("filename" "attachment.txt")) NIL NIL) ' b'"mixed" ("boundary" "===============0373402508605428821==") NIL NIL NIL))' ) parsed = parse_fetch_response([text]) self.assertEqual( parsed, { 1: { respType: ( [ ( [ ( b"text", b"html", (b"charset", b"utf-8"), None, None, b"7bit", 97, 3, None, None, None, None, ), ( b"text", b"plain", (b"charset", b"utf-8"), None, None, b"7bit", 62, 3, None, None, None, None, ), ], b"alternative", (b"boundary", b"===============8211050864078048428=="), None, None, None, ), ( b"text", b"plain", (b"charset", b"utf-8"), None, None, b"7bit", 16, 1, None, (b"attachment", (b"filename", b"attachment.txt")), None, None, ), ], b"mixed", (b"boundary", b"===============0373402508605428821=="), None, None, None, ), b"SEQ": 1, } }, ) self.assertTrue(parsed[1][respType].is_multipart) self.assertTrue(parsed[1][respType][0][0].is_multipart) self.assertFalse(parsed[1][respType][0][0][0][0].is_multipart) def test_partial_fetch(self): body = b"01234567890123456789" self.assertEqual( parse_fetch_response([(b"123 (UID 367 BODY[]<0> {20}", body), b")"]), {367: {b"BODY[]<0>": body, b"SEQ": 123}}, ) def test_ENVELOPE(self): envelope_str = ( b"1 (ENVELOPE ( " b'"Sun, 24 Mar 2013 22:06:10 +0200" ' b'"subject" ' b'(("name" NIL "address1" "domain1.com")) ' # from (name and address) b'((NIL NIL "address2" "domain2.com")) ' # sender (just address) b'(("name" NIL "address3" "domain3.com") NIL) ' # reply to b"NIL" # to (no address) b'((NIL NIL "address4" "domain4.com") ' # cc b'("person" NIL "address4b" "domain4b.com")) ' b"NIL " # bcc b'"" ' b'""))' ) output = parse_fetch_response([envelope_str], normalise_times=False) self.assertEqual( output[1][b"ENVELOPE"], Envelope( datetime(2013, 3, 24, 22, 6, 10, tzinfo=FixedOffset(120)), b"subject", (Address(b"name", None, b"address1", b"domain1.com"),), (Address(None, None, b"address2", b"domain2.com"),), (Address(b"name", None, b"address3", b"domain3.com"),), None, ( Address(None, None, b"address4", b"domain4.com"), Address(b"person", None, b"address4b", b"domain4b.com"), ), None, b"", b"", ), ) def test_ENVELOPE_with_no_date(self): envelope_str = ( b"1 (ENVELOPE ( " b"NIL " b'"subject" ' b"NIL " b"NIL " b"NIL " b"NIL " b"NIL " b"NIL " b'"" ' b'""))' ) output = parse_fetch_response([envelope_str], normalise_times=False) self.assertEqual( output[1][b"ENVELOPE"], Envelope( None, b"subject", None, None, None, None, None, None, b"", b"", ), ) def test_ENVELOPE_with_invalid_date(self): envelope_str = ( b"1 (ENVELOPE ( " b'"wtf" ' # bad date b'"subject" ' b"NIL NIL NIL NIL NIL NIL " b'"" ""))' ) output = parse_fetch_response([envelope_str], normalise_times=False) self.assertEqual( output[1][b"ENVELOPE"], Envelope( None, b"subject", None, None, None, None, None, None, b"", b"", ), ) def test_ENVELOPE_with_empty_addresses(self): envelope_str = ( b"1 (ENVELOPE ( " b"NIL " b'"subject" ' b'(("name" NIL "address1" "domain1.com") NIL) ' b'(NIL (NIL NIL "address2" "domain2.com")) ' b'(("name" NIL "address3" "domain3.com") NIL ("name" NIL "address3b" "domain3b.com")) ' b"NIL" b'((NIL NIL "address4" "domain4.com") ' b'("person" NIL "address4b" "domain4b.com")) ' b'NIL "" ""))' ) output = parse_fetch_response([envelope_str], normalise_times=False) self.assertEqual( output[1][b"ENVELOPE"], Envelope( None, b"subject", (Address(b"name", None, b"address1", b"domain1.com"),), (Address(None, None, b"address2", b"domain2.com"),), ( Address(b"name", None, b"address3", b"domain3.com"), Address(b"name", None, b"address3b", b"domain3b.com"), ), None, ( Address(None, None, b"address4", b"domain4.com"), Address(b"person", None, b"address4b", b"domain4b.com"), ), None, b"", b"", ), ) def test_INTERNALDATE(self): out = parse_fetch_response( [b'1 (INTERNALDATE " 9-Feb-2007 17:08:08 -0430")'], normalise_times=False ) self.assertEqual( out[1][b"INTERNALDATE"], datetime(2007, 2, 9, 17, 8, 8, 0, FixedOffset(-4 * 60 - 30)), ) def test_INTERNALDATE_normalised(self): output = parse_fetch_response( [b'3 (INTERNALDATE " 9-Feb-2007 17:08:08 -0430")'] ) dt = output[3][b"INTERNALDATE"] self.assertTrue(dt.tzinfo is None) # Returned date should be in local timezone expected_dt = datetime_to_native( datetime(2007, 2, 9, 17, 8, 8, 0, FixedOffset(-4 * 60 - 30)) ) self.assertEqual(dt, expected_dt) def test_INTERNALDATE_NIL(self): out = parse_fetch_response([b"1 (INTERNALDATE NIL)"]) self.assertEqual(out[1][b"INTERNALDATE"], None) def test_mixed_types(self): self.assertEqual( parse_fetch_response( [ ( b'1 (INTERNALDATE " 9-Feb-2007 17:08:08 +0100" RFC822 {21}', b"Subject: test\r\n\r\nbody", ), b")", ] ), { 1: { b"INTERNALDATE": datetime_to_native( datetime(2007, 2, 9, 17, 8, 8, 0, FixedOffset(60)) ), b"RFC822": b"Subject: test\r\n\r\nbody", b"SEQ": 1, } }, ) def test_Address_str(self): self.assertEqual( str(Address(b"Mary Jane", None, b"mary", b"jane.org")), "Mary Jane ", ) self.assertEqual( str(Address("Mary Jane", None, "mary", "jane.org")), "Mary Jane ", ) self.assertEqual( str(Address("Anonymous", None, "undisclosed-recipients", None)), "Anonymous ", ) self.assertEqual( str(Address(None, None, None, "undisclosed-recipients")), "undisclosed-recipients", ) @patch("imapclient.util.logger") def test_Address_str_ignores_encoding_error(self, mock_logger): self.assertEqual( str( Address( b"Russian \xc2\xeb\xe0\xe4\xe8\xec\xe8\xf0", None, b"g\xe9rard", "domain.org", ) ), "Russian ", ) # Ensure warning has been triggered twice, for name and mailbox bytes self.assertEqual(mock_logger.warning.call_count, 2) def add_crlf(text): return CRLF.join(text.splitlines()) + CRLF imapclient-3.0.1/tests/test_search.py000066400000000000000000000137211453256433100176720ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import imaplib from datetime import date, datetime from unittest.mock import Mock from imapclient.exceptions import InvalidCriteriaError from imapclient.imapclient import _quoted from .imapclient_test import IMAPClientTest class TestSearchBase(IMAPClientTest): def setUp(self): super(TestSearchBase, self).setUp() self.client._raw_command_untagged = Mock() self.client._raw_command_untagged.return_value = [b"1 2 44"] def check_call(self, expected_args): self.client._raw_command_untagged.assert_called_once_with( b"SEARCH", expected_args ) class TestSearch(TestSearchBase): def test_bytes_criteria(self): result = self.client.search([b"FOO", b"BAR"]) self.check_call([b"FOO", b"BAR"]) self.assertEqual(result, [1, 2, 44]) self.assertEqual(result.modseq, None) def test_bytes_criteria_with_charset(self): self.client.search([b"FOO", b"BAR"], "utf-92") self.check_call([b"CHARSET", b"utf-92", b"FOO", b"BAR"]) def test_unicode_criteria(self): result = self.client.search(["FOO", "BAR"]) # Default conversion using us-ascii. self.check_call([b"FOO", b"BAR"]) self.assertEqual(result, [1, 2, 44]) self.assertEqual(result.modseq, None) def test_unicode_criteria_with_charset(self): self.client.search(["FOO", "\u2639"], "utf-8") self.check_call([b"CHARSET", b"utf-8", b"FOO", _quoted(b"\xe2\x98\xb9")]) def test_with_date(self): self.client.search(["SINCE", date(2005, 4, 3)]) self.check_call([b"SINCE", b"03-Apr-2005"]) def test_with_datetime(self): self.client.search(["SINCE", datetime(2005, 4, 3, 2, 1, 0)]) self.check_call([b"SINCE", b"03-Apr-2005"]) # Time part is ignored def test_quoting(self): self.client.search(["TEXT", "foo bar"]) self.check_call([b"TEXT", _quoted(b'"foo bar"')]) def test_zero_length_quoting(self): # Zero-length strings should be quoted self.client.search(["HEADER", "List-Id", ""]) self.check_call([b"HEADER", b"List-Id", b'""']) def test_no_results(self): self.client._raw_command_untagged.return_value = [None] result = self.client.search(["FOO"]) self.assertEqual(result, []) self.assertEqual(result.modseq, None) def test_modseq(self): self.client._raw_command_untagged.return_value = [b"1 2 (MODSEQ 51101)"] result = self.client.search(["MODSEQ", "40000"]) self.check_call([b"MODSEQ", b"40000"]) self.assertEqual(result, [1, 2]) self.assertEqual(result.modseq, 51101) def test_nested_empty(self): self.assertRaises(InvalidCriteriaError, self.client.search, [[]]) def test_single(self): self.client.search([["FOO"]]) self.check_call([b"(FOO)"]) def test_nested(self): self.client.search(["NOT", ["SUBJECT", "topic", "TO", "some@email.com"]]) self.check_call([b"NOT", b"(SUBJECT", b"topic", b"TO", b"some@email.com)"]) def test_nested_multiple(self): self.client.search(["NOT", ["OR", ["A", "x", "B", "y"], ["C", "z"]]]) self.check_call([b"NOT", b"(OR", b"(A", b"x", b"B", b"y)", b"(C", b"z))"]) def test_nested_tuple(self): self.client.search(["NOT", ("SUBJECT", "topic", "TO", "some@email.com")]) self.check_call([b"NOT", b"(SUBJECT", b"topic", b"TO", b"some@email.com)"]) def test_search_custom_exception_with_invalid_list(self): def search_bad_command_exp(*args, **kwargs): raise imaplib.IMAP4.error( 'SEARCH command error: BAD ["Unknown argument NOT DELETED"]' ) self.client._raw_command_untagged.side_effect = search_bad_command_exp with self.assertRaises(imaplib.IMAP4.error) as cm: self.client.search(["NOT DELETED"]) self.assertIn( # Python 2.x will add a `u` prefix in the list representation, so let it handle the # representation of the criteria there too... "may have been caused by a syntax error in the criteria: %s" % str(["NOT DELETED"]), str(cm.exception), ) # Original exception message should be present too just in case... self.assertIn("Unknown argument NOT DELETED", str(cm.exception)) def test_search_custom_exception_with_invalid_text(self): # Check the criteria is surrounding with quotes if the user is using a plain text criteria def search_bad_command_exp2(*args, **kwargs): raise imaplib.IMAP4.error( 'SEARCH command error: BAD ["Unknown argument TOO"]' ) self.client._raw_command_untagged.side_effect = search_bad_command_exp2 with self.assertRaises(imaplib.IMAP4.error) as cm: self.client.search("TOO some@email.com") self.assertIn( 'may have been caused by a syntax error in the criteria: "TOO some@email.com"', str(cm.exception), ) self.assertIn("Unknown argument TOO", str(cm.exception)) class TestGmailSearch(TestSearchBase): def setUp(self): super(TestGmailSearch, self).setUp() self.client._cached_capabilities = [b"X-GM-EXT-1"] def test_bytes_query(self): result = self.client.gmail_search(b"foo bar") self.check_call([b"CHARSET", b"UTF-8", b"X-GM-RAW", b'"foo bar"']) self.assertEqual(result, [1, 2, 44]) def test_bytes_query_with_charset(self): result = self.client.gmail_search(b"foo bar", "utf-42") self.check_call([b"CHARSET", b"utf-42", b"X-GM-RAW", b'"foo bar"']) self.assertEqual(result, [1, 2, 44]) def test_unicode_criteria_with_charset(self): self.client.gmail_search("foo \u2639", "utf-8") self.check_call( [b"CHARSET", b"utf-8", b"X-GM-RAW", _quoted(b'"foo \xe2\x98\xb9"')] ) imapclient-3.0.1/tests/test_sort.py000066400000000000000000000025141453256433100174120ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from unittest.mock import Mock from imapclient.exceptions import CapabilityError from .imapclient_test import IMAPClientTest class TestSort(IMAPClientTest): def setUp(self): super(TestSort, self).setUp() self.client._cached_capabilities = (b"SORT",) self.client._raw_command_untagged = Mock() self.client._raw_command_untagged.return_value = b"9 8 7" def check_call(self, expected_args): self.client._raw_command_untagged.assert_called_once_with( b"SORT", expected_args, unpack=True ) def test_no_support(self): self.client._cached_capabilities = (b"BLAH",) self.assertRaises(CapabilityError, self.client.sort, "ARRIVAL") def test_single_criteria(self): ids = self.client.sort("arrival") self.check_call([b"(ARRIVAL)", b"UTF-8", b"ALL"]) self.assertSequenceEqual(ids, [9, 8, 7]) def test_multiple_criteria(self): self.client.sort(["arrival", b"SUBJECT"]) self.check_call([b"(ARRIVAL SUBJECT)", b"UTF-8", b"ALL"]) def test_all_args(self): self.client.sort("arrival", ["TEXT", "\u261e"], "UTF-7") self.check_call([b"(ARRIVAL)", b"UTF-7", b"TEXT", b"+Jh4-"]) imapclient-3.0.1/tests/test_starttls.py000066400000000000000000000043311453256433100203020ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from unittest.mock import Mock, patch, sentinel from imapclient.exceptions import IMAPClientError from imapclient.imapclient import IMAPClient from .imapclient_test import IMAPClientTest class TestStarttls(IMAPClientTest): def setUp(self): super(TestStarttls, self).setUp() patcher = patch("imapclient.imapclient.tls") self.tls = patcher.start() self.addCleanup(patcher.stop) self.client._imap.sock = sentinel.old_sock self.new_sock = Mock() self.new_sock.makefile.return_value = sentinel.file self.tls.wrap_socket.return_value = self.new_sock self.client.host = sentinel.host self.client.ssl = False self.client._starttls_done = False self.client._imap._simple_command.return_value = "OK", [ b"start TLS negotiation" ] self.client._cached_capabilities = [b"STARTTLS"] def test_works(self): resp = self.client.starttls(sentinel.ssl_context) self.tls.wrap_socket.assert_called_once_with( sentinel.old_sock, sentinel.ssl_context, sentinel.host, ) self.new_sock.makefile.assert_called_once_with("rb") self.assertEqual(self.client._imap.file, sentinel.file) self.assertEqual(resp, b"start TLS negotiation") def test_command_fails(self): self.client._imap._simple_command.return_value = "NO", [b"sorry"] with self.assertRaises(IMAPClientError) as raised: self.client.starttls(sentinel.ssl_context) self.assertEqual(str(raised.exception), "starttls failed: sorry") def test_fails_if_called_twice(self): self.client.starttls(sentinel.ssl_context) self.assert_tls_already_established() def test_fails_if_ssl_true(self): self.client.ssl = True self.assert_tls_already_established() def assert_tls_already_established(self): with self.assertRaises(IMAPClient.AbortError) as raised: self.client.starttls(sentinel.ssl_context) self.assertEqual(str(raised.exception), "TLS session already established") imapclient-3.0.1/tests/test_store.py000066400000000000000000000103351453256433100175570ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright (c) 2016, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from unittest.mock import Mock, patch, sentinel from imapclient.imapclient import ANSWERED, DELETED, DRAFT, FLAGGED, RECENT, SEEN from .imapclient_test import IMAPClientTest class TestFlagsConsts(IMAPClientTest): def test_flags_are_bytes(self): for flag in DELETED, SEEN, ANSWERED, FLAGGED, DRAFT, RECENT: if not isinstance(flag, bytes): self.fail("%r flag is not bytes" % flag) class TestFlags(IMAPClientTest): def setUp(self): super(TestFlags, self).setUp() self.client._command_and_check = Mock() def test_get(self): with patch.object( self.client, "fetch", autospec=True, return_value={123: {b"FLAGS": [b"foo", b"bar"]}, 444: {b"FLAGS": [b"foo"]}}, ): out = self.client.get_flags(sentinel.messages) self.client.fetch.assert_called_with(sentinel.messages, ["FLAGS"]) self.assertEqual(out, {123: [b"foo", b"bar"], 444: [b"foo"]}) def test_set(self): self.check(self.client.set_flags, b"FLAGS") def test_add(self): self.check(self.client.add_flags, b"+FLAGS") def test_remove(self): self.check(self.client.remove_flags, b"-FLAGS") def check(self, meth, expected_command): self._check(meth, expected_command) self._check(meth, expected_command, silent=True) def _check(self, meth, expected_command, silent=False): if silent: expected_command += b".SILENT" cc = self.client._command_and_check cc.return_value = [ b"11 (FLAGS (blah foo) UID 1)", b"11 (UID 1 OTHER (dont))", b"22 (FLAGS (foo) UID 2)", b"22 (UID 2 OTHER (care))", ] resp = meth([1, 2], "foo", silent=silent) cc.assert_called_once_with("store", b"1,2", expected_command, "(foo)", uid=True) if silent: self.assertIsNone(resp) else: self.assertEqual( resp, { 1: (b"blah", b"foo"), 2: (b"foo",), }, ) cc.reset_mock() class TestGmailLabels(IMAPClientTest): def setUp(self): super(TestGmailLabels, self).setUp() self.client._command_and_check = Mock() def test_get(self): with patch.object( self.client, "fetch", autospec=True, return_value={ 123: {b"X-GM-LABELS": [b"foo", b"&AUE-abel"]}, 444: {b"X-GM-LABELS": [b"foo"]}, }, ): out = self.client.get_gmail_labels(sentinel.messages) self.client.fetch.assert_called_with(sentinel.messages, [b"X-GM-LABELS"]) self.assertEqual(out, {123: ["foo", "Łabel"], 444: ["foo"]}) def test_set(self): self.check(self.client.set_gmail_labels, b"X-GM-LABELS") def test_add(self): self.check(self.client.add_gmail_labels, b"+X-GM-LABELS") def test_remove(self): self.check(self.client.remove_gmail_labels, b"-X-GM-LABELS") def check(self, meth, expected_command): self._check(meth, expected_command) self._check(meth, expected_command, silent=True) def _check(self, meth, expected_command, silent=False): if silent: expected_command += b".SILENT" cc = self.client._command_and_check cc.return_value = [ b'11 (X-GM-LABELS (&AUE-abel "f\\"o\\"o") UID 1)', b'22 (X-GM-LABELS ("f\\"o\\"o") UID 2)', b"11 (UID 1 FLAGS (dont))", b"22 (UID 2 FLAGS (care))", ] resp = meth([1, 2], 'f"o"o', silent=silent) cc.assert_called_once_with( "store", b"1,2", expected_command, '("f\\"o\\"o")', uid=True ) if silent: self.assertIsNone(resp) else: self.assertEqual( resp, { 1: ["Łabel", 'f"o"o'], 2: [ 'f"o"o', ], }, ) cc.reset_mock() imapclient-3.0.1/tests/test_thread.py000066400000000000000000000026651453256433100177010ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses from unittest.mock import Mock from imapclient.exceptions import CapabilityError from .imapclient_test import IMAPClientTest class TestThread(IMAPClientTest): def setUp(self): super(TestThread, self).setUp() self.client._cached_capabilities = (b"THREAD=REFERENCES",) self.client._raw_command_untagged = Mock() self.client._raw_command_untagged.return_value = [b"(1 2)(3)(4 5 6)"] def check_call(self, expected_args): self.client._raw_command_untagged.assert_called_once_with( b"THREAD", expected_args ) def test_no_thread_support(self): self.client._cached_capabilities = (b"NOT-THREAD",) self.assertRaises(CapabilityError, self.client.thread) def test_unsupported_algorithm(self): self.client._cached_capabilities = (b"THREAD=FOO",) self.assertRaises(CapabilityError, self.client.thread) def test_defaults(self): threads = self.client.thread() self.check_call([b"REFERENCES", b"UTF-8", b"ALL"]) self.assertSequenceEqual(threads, ((1, 2), (3,), (4, 5, 6))) def test_all_args(self): self.client._cached_capabilities = (b"THREAD=COTTON",) self.client.thread("COTTON", ["TEXT", "\u261e"], "UTF-7") self.check_call([b"COTTON", b"UTF-7", b"TEXT", b"+Jh4-"]) imapclient-3.0.1/tests/test_util_functions.py000066400000000000000000000116461453256433100214760ustar00rootroot00000000000000# Copyright (c) 2014, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import unittest from imapclient.exceptions import InvalidCriteriaError, ProtocolError from imapclient.imapclient import ( _normalise_search_criteria, _quoted, join_message_ids, normalise_text_list, seq_to_parenstr, seq_to_parenstr_upper, ) from imapclient.util import assert_imap_protocol class Test_normalise_text_list(unittest.TestCase): def check(self, items, expected): self.assertEqual(normalise_text_list(items), expected) def test_unicode(self): self.check("Foo", ["Foo"]) def test_binary(self): self.check(b"FOO", ["FOO"]) def test_tuple(self): self.check(("FOO", "BAR"), ["FOO", "BAR"]) def test_list(self): self.check(["FOO", "BAR"], ["FOO", "BAR"]) def test_iter(self): self.check(iter(["FOO", "BAR"]), ["FOO", "BAR"]) def test_mixed_list(self): self.check(["FOO", b"Bar"], ["FOO", "Bar"]) class Test_seq_to_parenstr(unittest.TestCase): def check(self, items, expected): self.assertEqual(seq_to_parenstr(items), expected) def test_unicode(self): self.check("foO", "(foO)") def test_binary(self): self.check(b"Foo", "(Foo)") def test_tuple(self): self.check(("FOO", "BAR"), "(FOO BAR)") def test_list(self): self.check(["FOO", "BAR"], "(FOO BAR)") def test_iter(self): self.check(iter(["FOO", "BAR"]), "(FOO BAR)") def test_mixed_list(self): self.check(["foo", b"BAR"], "(foo BAR)") class Test_seq_to_parenstr_upper(unittest.TestCase): def check(self, items, expected): self.assertEqual(seq_to_parenstr_upper(items), expected) def test_unicode(self): self.check("foO", "(FOO)") def test_binary(self): self.check(b"Foo", "(FOO)") def test_tuple(self): self.check(("foo", "BAR"), "(FOO BAR)") def test_list(self): self.check(["FOO", "bar"], "(FOO BAR)") def test_iter(self): self.check(iter(["FOO", "BaR"]), "(FOO BAR)") def test_mixed_list(self): self.check(["foo", b"BAR"], "(FOO BAR)") class Test_join_message_ids(unittest.TestCase): def check(self, items, expected): self.assertEqual(join_message_ids(items), expected) def test_int(self): self.check(123, b"123") def test_unicode(self): self.check("123", b"123") def test_unicode_non_numeric(self): self.check("2:*", b"2:*") def test_binary(self): self.check(b"123", b"123") def test_binary_non_numeric(self): self.check(b"2:*", b"2:*") def test_tuple(self): self.check((123, 99), b"123,99") def test_mixed_list(self): self.check(["2:3", 123, b"44"], b"2:3,123,44") def test_iter(self): self.check(iter([123, 99]), b"123,99") class Test_normalise_search_criteria(unittest.TestCase): def check(self, criteria, charset, expected): actual = _normalise_search_criteria(criteria, charset) self.assertEqual(actual, expected) # Go further and check exact types for a, e in zip(actual, expected): self.assertEqual( type(a), type(e), "type mismatch: %s (%r) != %s (%r) in %r" % (type(a), a, type(e), e, actual), ) def test_list(self): self.check(["FOO", "\u263a"], "utf-8", [b"FOO", b"\xe2\x98\xba"]) def test_tuple(self): self.check(("FOO", "BAR"), None, [b"FOO", b"BAR"]) def test_mixed_list(self): self.check(["FOO", b"BAR"], None, [b"FOO", b"BAR"]) def test_quoting(self): self.check(["foo bar"], None, [_quoted(b'"foo bar"')]) def test_ints(self): self.check(["modseq", 500], None, [b"modseq", b"500"]) def test_unicode(self): self.check("Foo", None, [b"Foo"]) def test_binary(self): self.check(b"FOO", None, [b"FOO"]) def test_unicode_with_charset(self): self.check("\u263a", "UTF-8", [b"\xe2\x98\xba"]) def test_binary_with_charset(self): # charset is unused when criteria is binary. self.check(b"FOO", "UTF-9", [b"FOO"]) def test_no_quoting_when_criteria_given_as_string(self): self.check("foo bar", None, [b"foo bar"]) def test_None(self): self.assertRaises(InvalidCriteriaError, _normalise_search_criteria, None, None) def test_empty(self): self.assertRaises(InvalidCriteriaError, _normalise_search_criteria, "", None) class TestAssertIMAPProtocol(unittest.TestCase): def test_assert_imap_protocol(self): assert_imap_protocol(True) with self.assertRaises(ProtocolError): assert_imap_protocol(False) def test_assert_imap_protocol_with_message(self): assert_imap_protocol(True, b"foo") with self.assertRaises(ProtocolError): assert_imap_protocol(False, b"foo") imapclient-3.0.1/tests/test_version.py000066400000000000000000000014651453256433100201140ustar00rootroot00000000000000# Copyright (c) 2015, Menno Smits # Released subject to the New BSD License # Please see http://en.wikipedia.org/wiki/BSD_licenses import unittest from imapclient.version import _imapclient_version_string class TestVersionString(unittest.TestCase): def test_dot_oh(self): self.assertEqual(_imapclient_version_string((1, 0, 0, "final")), "1.0.0") def test_minor(self): self.assertEqual(_imapclient_version_string((2, 1, 0, "final")), "2.1.0") def test_point_release(self): self.assertEqual(_imapclient_version_string((1, 2, 3, "final")), "1.2.3") def test_alpha(self): self.assertEqual(_imapclient_version_string((2, 1, 0, "alpha")), "2.1.0-alpha") def test_beta_point(self): self.assertEqual(_imapclient_version_string((2, 1, 3, "beta")), "2.1.3-beta") imapclient-3.0.1/tox-all000077500000000000000000000001621453256433100151530ustar00rootroot00000000000000#!/bin/sh sudo docker run -v $(pwd):/mnt --rm -it docker.io/fkrull/multi-python:bionic bash -c "cd /mnt; tox $*" imapclient-3.0.1/tox.ini000066400000000000000000000014671453256433100151710ustar00rootroot00000000000000[tox] skipsdist = True minversion = 3.0 envlist=py37,py38,py39,py310,py311,black,isort,flake8,mypy,pylint [testenv] commands=python -m unittest deps = -r{toxinidir}/requirements-dev.txt [testenv:black] basepython = python3 commands = black {posargs} . [testenv:flake8] basepython = python3 commands = flake8 {posargs} . [testenv:isort] basepython = python3 commands = isort {posargs} . [testenv:mypy] basepython = python3 commands = mypy {posargs} [testenv:pylint] basepython = python3 commands = pylint {posargs} imapclient/ [flake8] exclude = .git,.venv,.tox,dist,doc,*egg,build, max-line-length = 88 # We ignore the following because we use black to handle code-formatting # E203: Whitespace before ':' # E501: Line too long # W503: Line break occurred before a binary operator ignore = E203,E501,W503