├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── imbox ├── __init__.py ├── imap.py ├── imap.pyi ├── imbox.py ├── imbox.pyi ├── messages.py ├── messages.pyi ├── parser.py ├── parser.pyi ├── query.py ├── utils.py ├── utils.pyi ├── vendors │ ├── __init__.py │ ├── __init__.pyi │ ├── gmail.py │ ├── gmail.pyi │ └── helpers.py └── version.py ├── poetry.lock ├── pyproject.toml ├── tests ├── 8422.msg ├── __init__.py ├── parser_tests.py └── query_tests.py └── tox.ini /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Imbox Tests 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install flake8 nose chardet 29 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 30 | - name: Lint with flake8 31 | run: | 32 | # stop the build if there are Python syntax errors or undefined names 33 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 34 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 35 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 36 | - name: Test with nose 37 | run: | 38 | nosetests -v 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | example.* 33 | example.py 34 | 35 | # PyCharm 36 | .idea/ 37 | 38 | # Mac 39 | .DS_Store -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.9.9 (17 November 2022) 2 | 3 | ### What's Changed 4 | 5 | * Add query uid__range by @skulltech in https://github.com/martinrusev/imbox/pull/153 6 | * fix substring search of subjects in Gmail, add support for some Gmail extended IMAP by @zevaverbach in https://github.com/martinrusev/imbox/pull/155 7 | * Support filter message by mail body by @daassh in https://github.com/martinrusev/imbox/pull/166 8 | * Attachments now getting Content-ID by @Anderseta in https://github.com/martinrusev/imbox/pull/174 9 | * Update parser.py in https://github.com/martinrusev/imbox/pull/192 10 | * Avoiding the error - ValueError: invalid literal for int() with base 10 by @Anderseta in https://github.com/martinrusev/imbox/pull/201 11 | * fix false exception on unknown encoding #202 by @kapalex in https://github.com/martinrusev/imbox/pull/203 12 | * Fix binascii.Error: Incorrect padding by @Anderseta in https://github.com/martinrusev/imbox/pull/204 13 | * Preserve timezone info in date parsing by @AT0myks in https://github.com/martinrusev/imbox/pull/205 14 | * Fix ignored headers + unnecessary major version check by @AT0myks in https://github.com/martinrusev/imbox/pull/206 15 | * Local variable 'filename' value is not used by @tveronesi in https://github.com/martinrusev/imbox/pull/211 16 | * Date handling improvement and various fixes by @AT0myks in https://github.com/martinrusev/imbox/pull/218 17 | * Fix crash when semicolon present in attachment name by @nicknytko in https://github.com/martinrusev/imbox/pull/219 18 | * Base64 decode param and recognize single file mails as attachment by @engelant in https://github.com/martinrusev/imbox/pull/224 19 | * [Fix] parse_attachment > cannot parse name by @jimmi2051 in https://github.com/martinrusev/imbox/pull/228 20 | * Should first get content charset then str_encode with charset. by @sangkaka in https://github.com/martinrusev/imbox/pull/231 21 | * fix append and join of param parts by @oberix in https://github.com/martinrusev/imbox/pull/232 22 | 23 | 24 | ## 0.9.8 (02 June 2020) 25 | 26 | IMPROVEMENTS: 27 | 28 | * Fix imbox.delete regression ([#138](https://github.com/martinrusev/imbox/issues/138)) 29 | * Fixed handling for attachments with filenames longer than 76 characters ([#186](https://github.com/martinrusev/imbox/pull/186)) - Contributed by @nirdrabkin 30 | * Improved character encoding detection ([#184](https://github.com/martinrusev/imbox/pull/184)) - Contributed by @py-radicz 31 | 32 | ## 0.9.7 (03 May 2020) 33 | 34 | IMPROVEMENTS: 35 | 36 | * Gmail: IMAP extension searches label and raw are not supported. 37 | * Searches in mail bodies and UID ranges are now supported. 38 | * Attachments have a Content-ID now (#174) 39 | 40 | ## 0.9.6 (14 August 2018) 41 | 42 | IMPROVEMENTS: 43 | 44 | * Vendors package, adding provider specific functionality ([#139](https://github.com/martinrusev/imbox/pull/139)) - Contributed by @zevaverbach 45 | * Type hints for every method and function ([#136](https://github.com/martinrusev/imbox/pull/136)) - Contributed by @zevaverbach 46 | * Move all code out of __init__.py and into a separate module ([#130](https://github.com/martinrusev/imbox/pull/130)) - Contributed by @zevaverbach 47 | * Enhance `messages' generator: ([#129](https://github.com/martinrusev/imbox/pull/129)) - Contributed by @zevaverbach 48 | 49 | 50 | ## 0.9.5 (5 December 2017) 51 | 52 | IMPROVEMENTS: 53 | 54 | * `date__on` support: ([#109](https://github.com/martinrusev/imbox/pull/109)) - Contributed by @balsagoth 55 | * Starttls support: ([#108](https://github.com/martinrusev/imbox/pull/108)) - Contributed by @balsagoth 56 | * Mark emails as flagged/starred: ([#107](https://github.com/martinrusev/imbox/pull/107)) - Contributed by @memanikantan 57 | * Messages filter can use date objects instead of stringified dates: ([#104](https://github.com/martinrusev/imbox/pull/104)) - Contributed by @sblondon 58 | * Fix attachment parsing when a semicolon character ends the Content-Disposition line: ([#100](https://github.com/martinrusev/imbox/pull/100)) - Contributed by @sblondon 59 | * Parsing - UnicecodeDecodeError() fixes: ([#96](https://github.com/martinrusev/imbox/pull/96)) - Contributed by @am0z 60 | * Imbox() `with` support: ([#92](https://github.com/martinrusev/imbox/pull/92)) - Contributed by @sblondon 61 | 62 | 63 | ## 0.9 (18 September 2017) 64 | 65 | IMPROVEMENTS: 66 | 67 | * Permissively Decode Emails: ([#78](https://github.com/martinrusev/imbox/pull/78)) - Contributed by @AdamNiederer 68 | * "With" statement for automatic cleanup/logout ([#92](https://github.com/martinrusev/imbox/pull/92)) - Contributed by @sblondon 69 | 70 | 71 | 72 | ## 0.8.6 (6 December 2016) 73 | 74 | IMPROVEMENTS: 75 | 76 | * Add support for Python 3.3+ Parsing policies: ([#75](https://github.com/martinrusev/imbox/pull/75)) - Contributed by @bhtucker 77 | 78 | BACKWARDS INCOMPATIBILITIES / NOTES: 79 | 80 | * Remove support for Python 2.7 81 | 82 | ## 0.8.5 (9 June 2016) 83 | 84 | 85 | IMPROVEMENTS: 86 | 87 | * ssl_context: Check SSLContext for IMAP4_SSL connections ([#69](https://github.com/martinrusev/imbox/pull/69)) - Contributed by @dmth 88 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Martin Rusev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include MANIFEST.in 3 | include README.md 4 | include CHANGELOG.md 5 | graft tests 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | publish: 2 | poetry build 3 | poetry publish 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Imbox - Python IMAP for Humans 2 | 3 | ![workflow](https://github.com/martinrusev/imbox/actions/workflows/python-app.yml/badge.svg) 4 | 5 | Python library for reading IMAP mailboxes and converting email content 6 | to machine readable data 7 | 8 | ## Requirements 9 | 10 | Python (3.6, 3.7, 3.8, 3.9) 11 | 12 | ## Installation 13 | 14 | `pip install imbox` 15 | 16 | ## Usage 17 | 18 | ``` python 19 | from imbox import Imbox 20 | 21 | # SSL Context docs https://docs.python.org/3/library/ssl.html#ssl.create_default_context 22 | 23 | with Imbox('imap.gmail.com', 24 | username='username', 25 | password='password', 26 | ssl=True, 27 | ssl_context=None, 28 | starttls=False) as imbox: 29 | 30 | # Get all folders 31 | status, folders_with_additional_info = imbox.folders() 32 | 33 | # Gets all messages from the inbox 34 | all_inbox_messages = imbox.messages() 35 | 36 | # Unread messages 37 | unread_inbox_messages = imbox.messages(unread=True) 38 | 39 | # Flagged messages 40 | inbox_flagged_messages = imbox.messages(flagged=True) 41 | 42 | # Un-flagged messages 43 | inbox_unflagged_messages = imbox.messages(unflagged=True) 44 | 45 | # Flagged messages 46 | flagged_messages = imbox.messages(flagged=True) 47 | 48 | # Un-flagged messages 49 | unflagged_messages = imbox.messages(unflagged=True) 50 | 51 | # Messages sent FROM 52 | inbox_messages_from = imbox.messages(sent_from='sender@example.org') 53 | 54 | # Messages sent TO 55 | inbox_messages_to = imbox.messages(sent_to='receiver@example.org') 56 | 57 | # Messages received before specific date 58 | inbox_messages_received_before = imbox.messages(date__lt=datetime.date(2018, 7, 31)) 59 | 60 | # Messages received after specific date 61 | inbox_messages_received_after = imbox.messages(date__gt=datetime.date(2018, 7, 30)) 62 | 63 | # Messages received on a specific date 64 | inbox_messages_received_on_date = imbox.messages(date__on=datetime.date(2018, 7, 30)) 65 | 66 | # Messages whose subjects contain a string 67 | inbox_messages_subject_christmas = imbox.messages(subject='Christmas') 68 | 69 | # Messages whose UID is greater than 1050 70 | inbox_messages_uids_greater_than_1050 = imbox.messages(uid__range='1050:*') 71 | 72 | # Messages from a specific folder 73 | messages_in_folder_social = imbox.messages(folder='Social') 74 | 75 | # Some of Gmail's IMAP Extensions are supported (label and raw): 76 | all_messages_with_an_attachment_from_martin = imbox.messages(folder='all', raw='from:martin@amon.cx has:attachment') 77 | all_messages_labeled_finance = imbox.messages(folder='all', label='finance') 78 | 79 | for uid, message in all_inbox_messages: 80 | # Every message is an object with the following keys 81 | 82 | message.sent_from 83 | message.sent_to 84 | message.subject 85 | message.headers 86 | message.message_id 87 | message.date 88 | message.body.plain 89 | ``` 90 | -------------------------------------------------------------------------------- /imbox/__init__.py: -------------------------------------------------------------------------------- 1 | from imbox.imbox import Imbox 2 | 3 | __all__ = ['Imbox'] 4 | -------------------------------------------------------------------------------- /imbox/imap.py: -------------------------------------------------------------------------------- 1 | from imaplib import IMAP4, IMAP4_SSL 2 | 3 | import logging 4 | import ssl as pythonssllib 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class ImapTransport: 10 | 11 | def __init__(self, hostname, port=None, ssl=True, ssl_context=None, starttls=False): 12 | self.hostname = hostname 13 | 14 | if ssl: 15 | self.port = port or 993 16 | if ssl_context is None: 17 | ssl_context = pythonssllib.create_default_context() 18 | self.server = IMAP4_SSL(self.hostname, self.port, ssl_context=ssl_context) 19 | else: 20 | self.port = port or 143 21 | self.server = IMAP4(self.hostname, self.port) 22 | 23 | if starttls: 24 | self.server.starttls() 25 | logger.debug("Created IMAP4 transport for {host}:{port}" 26 | .format(host=self.hostname, port=self.port)) 27 | 28 | def list_folders(self): 29 | logger.debug("List all folders in mailbox") 30 | return self.server.list() 31 | 32 | def connect(self, username, password): 33 | self.server.login(username, password) 34 | self.server.select() 35 | logger.debug("Logged into server {} and selected mailbox 'INBOX'" 36 | .format(self.hostname)) 37 | return self.server 38 | -------------------------------------------------------------------------------- /imbox/imap.pyi: -------------------------------------------------------------------------------- 1 | from imaplib import IMAP4, IMAP4_SSL 2 | from ssl import SSLContext 3 | from typing import Optional, Union, Tuple, List 4 | 5 | 6 | class ImapTransport: 7 | 8 | def __init__(self, hostname: str, port: Optional[int], ssl: bool, 9 | ssl_context: Optional[SSLContext], starttls: bool) -> None: ... 10 | 11 | def list_folders(self) -> Tuple[str, List[bytes]]: ... 12 | 13 | def connect(self, username: str, password: str) -> Union[IMAP4, IMAP4_SSL]: ... 14 | -------------------------------------------------------------------------------- /imbox/imbox.py: -------------------------------------------------------------------------------- 1 | import imaplib 2 | 3 | from imbox.imap import ImapTransport 4 | from imbox.messages import Messages 5 | 6 | import logging 7 | 8 | from imbox.vendors import GmailMessages, hostname_vendorname_dict, name_authentication_string_dict 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Imbox: 14 | 15 | authentication_error_message = None 16 | 17 | def __init__(self, hostname, username=None, password=None, ssl=True, 18 | port=None, ssl_context=None, policy=None, starttls=False, 19 | vendor=None): 20 | 21 | self.server = ImapTransport(hostname, ssl=ssl, port=port, 22 | ssl_context=ssl_context, starttls=starttls) 23 | 24 | self.hostname = hostname 25 | self.username = username 26 | self.password = password 27 | self.parser_policy = policy 28 | self.vendor = vendor or hostname_vendorname_dict.get(self.hostname) 29 | 30 | if self.vendor is not None: 31 | self.authentication_error_message = name_authentication_string_dict.get( 32 | self.vendor) 33 | 34 | try: 35 | self.connection = self.server.connect(username, password) 36 | except imaplib.IMAP4.error as e: 37 | if self.authentication_error_message is None: 38 | raise 39 | raise imaplib.IMAP4.error( 40 | self.authentication_error_message + '\n' + str(e)) 41 | 42 | logger.info("Connected to IMAP Server with user {username} on {hostname}{ssl}".format( 43 | hostname=hostname, username=username, ssl=(" over SSL" if ssl or starttls else ""))) 44 | 45 | def __enter__(self): 46 | return self 47 | 48 | def __exit__(self, type, value, traceback): 49 | self.logout() 50 | 51 | def logout(self): 52 | self.connection.close() 53 | self.connection.logout() 54 | logger.info("Disconnected from IMAP Server {username}@{hostname}".format( 55 | hostname=self.hostname, username=self.username)) 56 | 57 | def mark_seen(self, uid): 58 | logger.info("Mark UID {} with \\Seen FLAG".format(int(uid))) 59 | self.connection.uid('STORE', uid, '+FLAGS', '(\\Seen)') 60 | 61 | def mark_flag(self, uid): 62 | logger.info("Mark UID {} with \\Flagged FLAG".format(int(uid))) 63 | self.connection.uid('STORE', uid, '+FLAGS', '(\\Flagged)') 64 | 65 | def delete(self, uid): 66 | logger.info( 67 | "Mark UID {} with \\Deleted FLAG and expunge.".format(int(uid))) 68 | self.connection.uid('STORE', uid, '+FLAGS', '(\\Deleted)') 69 | self.connection.expunge() 70 | 71 | def copy(self, uid, destination_folder): 72 | logger.info("Copy UID {} to {} folder".format( 73 | int(uid), str(destination_folder))) 74 | return self.connection.uid('COPY', uid, destination_folder) 75 | 76 | def move(self, uid, destination_folder): 77 | logger.info("Move UID {} to {} folder".format( 78 | int(uid), str(destination_folder))) 79 | if self.copy(uid, destination_folder): 80 | self.delete(uid) 81 | 82 | def messages(self, **kwargs): 83 | folder = kwargs.get('folder', False) 84 | 85 | messages_class = Messages 86 | 87 | if self.vendor == 'gmail': 88 | messages_class = GmailMessages 89 | 90 | if folder: 91 | status, data = self.connection.select( 92 | messages_class.FOLDER_LOOKUP.get((folder.lower())) or folder) 93 | if status != "OK": 94 | raise imaplib.IMAP4.error(data[-1]) 95 | msg = " from folder '{}'".format(folder) 96 | del kwargs['folder'] 97 | else: 98 | msg = " from inbox" 99 | 100 | logger.info("Fetch list of messages{}".format(msg)) 101 | 102 | return messages_class(connection=self.connection, 103 | parser_policy=self.parser_policy, 104 | **kwargs) 105 | 106 | def folders(self): 107 | return self.connection.list() 108 | -------------------------------------------------------------------------------- /imbox/imbox.pyi: -------------------------------------------------------------------------------- 1 | import datetime 2 | from email._policybase import Policy 3 | from inspect import Traceback 4 | from ssl import SSLContext 5 | from typing import Optional, Union, Tuple, List 6 | 7 | 8 | class Imbox: 9 | 10 | def __init__(self, hostname: str, username: Optional[str], password: Optional[str], ssl: bool, 11 | port: Optional[int], ssl_context: Optional[SSLContext], policy: Optional[Policy], starttls: bool): ... 12 | 13 | def __enter__(self) -> 'Imbox': ... 14 | 15 | def __exit__(self, type: Exception, value: str, traceback: Traceback) -> None: ... 16 | 17 | def logout(self) -> None: ... 18 | 19 | def mark_seen(self, uid: bytes) -> None: ... 20 | 21 | def mark_flag(self, uid: bytes) -> None: ... 22 | 23 | def delete(self, uid: bytes) -> None: ... 24 | 25 | def copy(self, uid: bytes, destination_folder: Union[bytes, str]) -> Tuple[str, Union[list, List[None, bytes]]]: ... 26 | 27 | def move(self, uid: bytes, destination_folder: Union[bytes, str]) -> None: ... 28 | 29 | def messages(self, **kwargs: Union[bool, str, datetime.date]) -> 'Messages': ... 30 | 31 | def folders(self) -> Tuple[str, List[bytes]]: ... -------------------------------------------------------------------------------- /imbox/messages.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | from imbox.query import build_search_query 5 | from imbox.parser import fetch_email_by_uid 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Messages: 12 | 13 | IMAP_ATTRIBUTE_LOOKUP = { 14 | 'unread': '(UNSEEN)', 15 | 'flagged': '(FLAGGED)', 16 | 'unflagged': '(UNFLAGGED)', 17 | 'sent_from': '(FROM "{}")', 18 | 'sent_to': '(TO "{}")', 19 | 'date__gt': '(SINCE "{}")', 20 | 'date__lt': '(BEFORE "{}")', 21 | 'date__on': '(ON "{}")', 22 | 'subject': '(SUBJECT "{}")', 23 | 'uid__range': '(UID {})', 24 | 'text': '(TEXT "{}")', 25 | } 26 | 27 | FOLDER_LOOKUP = {} 28 | 29 | def __init__(self, 30 | connection, 31 | parser_policy, 32 | **kwargs): 33 | 34 | self.connection = connection 35 | self.parser_policy = parser_policy 36 | self.kwargs = kwargs 37 | self._uid_list = self._query_uids(**kwargs) 38 | 39 | logger.debug("Fetch all messages for UID in {}".format(self._uid_list)) 40 | 41 | def _fetch_email(self, uid): 42 | return fetch_email_by_uid(uid=uid, 43 | connection=self.connection, 44 | parser_policy=self.parser_policy) 45 | 46 | def _query_uids(self, **kwargs): 47 | query_ = build_search_query(self.IMAP_ATTRIBUTE_LOOKUP, **kwargs) 48 | _, data = self.connection.uid('search', None, query_) 49 | if data[0] is None: 50 | return [] 51 | return data[0].split() 52 | 53 | def _fetch_email_list(self): 54 | for uid in self._uid_list: 55 | yield uid, self._fetch_email(uid) 56 | 57 | def __repr__(self): 58 | if len(self.kwargs) > 0: 59 | return 'Messages({})'.format('\n'.join('{}={}'.format(key, value) 60 | for key, value in self.kwargs.items())) 61 | return 'Messages(ALL)' 62 | 63 | def __iter__(self): 64 | return self._fetch_email_list() 65 | 66 | def __next__(self): 67 | return self 68 | 69 | def __len__(self): 70 | return len(self._uid_list) 71 | 72 | def __getitem__(self, index): 73 | uids = self._uid_list[index] 74 | 75 | if not isinstance(uids, list): 76 | uid = uids 77 | return uid, self._fetch_email(uid) 78 | 79 | return [(uid, self._fetch_email(uid)) 80 | for uid in uids] 81 | -------------------------------------------------------------------------------- /imbox/messages.pyi: -------------------------------------------------------------------------------- 1 | import datetime 2 | from email._policybase import Policy 3 | from imaplib import IMAP4, IMAP4_SSL 4 | from typing import Union, List, Generator, Tuple 5 | 6 | 7 | class Messages: 8 | 9 | def __init__(self, 10 | connection: Union[IMAP4, IMAP4_SSL], 11 | parser_policy: Policy, 12 | **kwargs: Union[bool, str, datetime.date]) -> None: ... 13 | 14 | def _fetch_email(self, uid: bytes) -> 'Struct': ... 15 | 16 | def _query_uids(self, **kwargs: Union[bool, str, datetime.date]) -> List[bytes]: ... 17 | 18 | def _fetch_email_list(self) -> Generator[Tuple[bytes, 'Struct']]: ... 19 | 20 | def __repr__(self) -> str: ... 21 | 22 | def __iter__(self) -> Generator[Tuple[bytes, 'Struct']]: ... 23 | 24 | def __next__(self) -> 'Messages': ... 25 | 26 | def __len__(self) -> int: ... 27 | 28 | def __getitem__(self, index) -> Union['Struct', List['Struct']]: ... -------------------------------------------------------------------------------- /imbox/parser.py: -------------------------------------------------------------------------------- 1 | import imaplib 2 | import io 3 | import re 4 | import email 5 | import chardet 6 | import base64 7 | import quopri 8 | import time 9 | from datetime import datetime 10 | from email.header import decode_header 11 | from imbox.utils import str_encode, str_decode 12 | 13 | import logging 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Struct: 19 | def __init__(self, **entries): 20 | self.__dict__.update(entries) 21 | 22 | def keys(self): 23 | return self.__dict__.keys() 24 | 25 | def __repr__(self): 26 | return str(self.__dict__) 27 | 28 | 29 | def decode_mail_header(value, default_charset='us-ascii'): 30 | """ 31 | Decode a header value into a unicode string. 32 | """ 33 | try: 34 | headers = decode_header(value) 35 | except email.errors.HeaderParseError: 36 | return str_decode(str_encode(value, default_charset, 'replace'), default_charset) 37 | else: 38 | for index, (text, charset) in enumerate(headers): 39 | try: 40 | logger.debug("Mail header no. {index}: {data} encoding {charset}".format( 41 | index=index, 42 | data=str_decode(text, charset or 'utf-8', 'replace'), 43 | charset=charset)) 44 | headers[index] = str_decode(text, charset or default_charset, 45 | 'replace') 46 | except LookupError: 47 | # if the charset is unknown, force default 48 | headers[index] = str_decode(text, default_charset, 'replace') 49 | 50 | return ''.join(headers) 51 | 52 | 53 | def get_mail_addresses(message, header_name): 54 | """ 55 | Retrieve all email addresses from one message header. 56 | """ 57 | headers = [h for h in message.get_all(header_name, [])] 58 | addresses = email.utils.getaddresses(headers) 59 | 60 | for index, (address_name, address_email) in enumerate(addresses): 61 | addresses[index] = {'name': decode_mail_header(address_name), 62 | 'email': address_email} 63 | logger.debug("{} Mail address in message: <{}> {}".format( 64 | header_name.upper(), address_name, address_email)) 65 | return addresses 66 | 67 | 68 | def decode_param(param): 69 | name, v = param.split('=', 1) 70 | values = v.split('\n') 71 | value_results = [] 72 | for value in values: 73 | match = re.findall(r'=\?((?:\w|-)+)\?([QB])\?(.+?)\?=', value) 74 | if match: 75 | for encoding, type_, code in match: 76 | if type_ == 'Q': 77 | value = quopri.decodestring(code) 78 | elif type_ == 'B': 79 | value = code.encode() 80 | missing_padding = len(value) % 4 81 | 82 | if missing_padding: 83 | value += b"=" * (4 - missing_padding) 84 | 85 | value = base64.b64decode(value) 86 | 87 | value = str_encode(value, encoding) 88 | 89 | value_results.append(value) 90 | 91 | if value_results: 92 | v = ''.join(value_results) 93 | 94 | logger.debug("Decoded parameter {} - {}".format(name, v)) 95 | return name, v 96 | 97 | 98 | def parse_content_disposition(content_disposition): 99 | # Split content disposition on semicolon except when inside a string 100 | in_quote = False 101 | str_start = 0 102 | ret = [] 103 | 104 | for i in range(len(content_disposition)): 105 | if content_disposition[i] == ';' and not in_quote: 106 | ret.append(content_disposition[str_start:i]) 107 | str_start = i+1 108 | elif content_disposition[i] == '"' or content_disposition[i] == "'": 109 | in_quote = not in_quote 110 | 111 | if str_start < len(content_disposition): 112 | ret.append(content_disposition[str_start:]) 113 | 114 | return ret 115 | 116 | 117 | 118 | def parse_attachment(message_part): 119 | # Check again if this is a valid attachment 120 | content_disposition = message_part.get("Content-Disposition", None) 121 | if content_disposition is not None and not message_part.is_multipart(): 122 | dispositions = [ 123 | disposition.strip() 124 | for disposition in parse_content_disposition(content_disposition) 125 | if disposition.strip() 126 | ] 127 | 128 | if dispositions[0].lower() in ["attachment", "inline"]: 129 | file_data = message_part.get_payload(decode=True) 130 | 131 | attachment = { 132 | 'content-type': message_part.get_content_type(), 133 | 'size': len(file_data), 134 | 'content': io.BytesIO(file_data), 135 | 'content-id': message_part.get("Content-ID", None) 136 | } 137 | filename_parts = [] 138 | for param in dispositions[1:]: 139 | if param: 140 | name, value = decode_param(param) 141 | 142 | # Check for split filename 143 | s_name = name.rstrip('*').split("*") 144 | if s_name[0] == 'filename': 145 | try: 146 | # If this is a split file name - use the number after the * as an index to insert this part 147 | if len(s_name) > 1 and s_name[1] != '': 148 | filename_parts.insert(int(s_name[1]),value[1:-1] if value.startswith('"') else value) 149 | else: 150 | filename_parts.insert(0,value[1:-1] if value.startswith('"') else value) 151 | except Exception as err: 152 | logger.debug('Parse attachment name error: %s', err) 153 | filename_parts.insert(0, value) 154 | 155 | if 'create-date' in name: 156 | attachment['create-date'] = value 157 | 158 | attachment['filename'] = "".join(filename_parts) 159 | return attachment 160 | 161 | return None 162 | 163 | 164 | def decode_content(message): 165 | content = message.get_payload(decode=True) 166 | charset = message.get_content_charset('utf-8') 167 | try: 168 | return content.decode(charset, 'ignore') 169 | except LookupError: 170 | encoding = chardet.detect(content).get('encoding') 171 | if encoding: 172 | return content.decode(encoding, 'ignore') 173 | return content 174 | except AttributeError: 175 | return content 176 | 177 | 178 | def fetch_email_by_uid(uid, connection, parser_policy): 179 | message, data = connection.uid('fetch', uid, '(BODY.PEEK[] FLAGS)') 180 | logger.debug("Fetched message for UID {}".format(int(uid))) 181 | 182 | raw_headers = data[0][0] + data[1] 183 | raw_email = data[0][1] 184 | 185 | email_object = parse_email(raw_email, policy=parser_policy) 186 | flags = parse_flags(raw_headers.decode()) 187 | email_object.__dict__['flags'] = flags 188 | 189 | return email_object 190 | 191 | 192 | def parse_flags(headers): 193 | """Copied from https://github.com/girishramnani/gmail/blob/master/gmail/message.py""" 194 | if len(headers) == 0: 195 | return [] 196 | headers = bytes(headers, "ascii") 197 | return list(imaplib.ParseFlags(headers)) 198 | 199 | 200 | def parse_email(raw_email, policy=None): 201 | if policy is not None: 202 | email_parse_kwargs = dict(policy=policy) 203 | else: 204 | email_parse_kwargs = {} 205 | 206 | # Should first get content charset then str_encode with charset. 207 | if isinstance(raw_email, bytes): 208 | email_message = email.message_from_bytes( 209 | raw_email, **email_parse_kwargs) 210 | charset = email_message.get_content_charset('utf-8') 211 | raw_email = str_encode(raw_email, charset, errors='ignore') 212 | else: 213 | try: 214 | email_message = email.message_from_string( 215 | raw_email, **email_parse_kwargs) 216 | except UnicodeEncodeError: 217 | email_message = email.message_from_string( 218 | raw_email.encode('utf-8'), **email_parse_kwargs) 219 | 220 | maintype = email_message.get_content_maintype() 221 | parsed_email = {'raw_email': raw_email} 222 | 223 | body = { 224 | "plain": [], 225 | "html": [] 226 | } 227 | attachments = [] 228 | 229 | if maintype in ('multipart', 'image'): 230 | logger.debug("Multipart message. Will process parts.") 231 | for part in email_message.walk(): 232 | content_type = part.get_content_type() 233 | part_maintype = part.get_content_maintype() 234 | content_disposition = part.get('Content-Disposition', None) 235 | if content_disposition or not part_maintype == "text": 236 | content = part.get_payload(decode=True) 237 | else: 238 | content = decode_content(part) 239 | 240 | is_inline = content_disposition is None \ 241 | or content_disposition.startswith("inline") 242 | if content_type == "text/plain" and is_inline: 243 | body['plain'].append(content) 244 | elif content_type == "text/html" and is_inline: 245 | body['html'].append(content) 246 | elif content_disposition: 247 | attachment = parse_attachment(part) 248 | if attachment: 249 | attachments.append(attachment) 250 | 251 | elif maintype == 'text': 252 | payload = decode_content(email_message) 253 | body['plain'].append(payload) 254 | 255 | elif maintype == 'application': 256 | if email_message.get_content_subtype() == 'pdf': 257 | attachment = parse_attachment(email_message) 258 | if attachment: 259 | attachments.append(attachment) 260 | 261 | parsed_email['attachments'] = attachments 262 | 263 | parsed_email['body'] = body 264 | email_dict = dict(email_message.items()) 265 | 266 | parsed_email['sent_from'] = get_mail_addresses(email_message, 'from') 267 | parsed_email['sent_to'] = get_mail_addresses(email_message, 'to') 268 | parsed_email['cc'] = get_mail_addresses(email_message, 'cc') 269 | parsed_email['bcc'] = get_mail_addresses(email_message, 'bcc') 270 | 271 | value_headers_keys = ['subject', 'date', 'message-id'] 272 | key_value_header_keys = ['received-spf', 273 | 'mime-version', 274 | 'x-spam-status', 275 | 'x-spam-score', 276 | 'content-type'] 277 | 278 | parsed_email['headers'] = [] 279 | for key, value in email_dict.items(): 280 | 281 | if key.lower() in value_headers_keys: 282 | valid_key_name = key.lower().replace('-', '_') 283 | parsed_email[valid_key_name] = decode_mail_header(value) 284 | 285 | if key.lower() in key_value_header_keys: 286 | parsed_email['headers'].append({'Name': key, 287 | 'Value': value}) 288 | 289 | if parsed_email.get('date'): 290 | parsed_email['parsed_date'] = email.utils.parsedate_to_datetime(parsed_email['date']) 291 | 292 | logger.info("Downloaded and parsed mail '{}' with {} attachments".format( 293 | parsed_email.get('subject'), len(parsed_email.get('attachments')))) 294 | return Struct(**parsed_email) 295 | -------------------------------------------------------------------------------- /imbox/parser.pyi: -------------------------------------------------------------------------------- 1 | import datetime 2 | from email._policybase import Policy 3 | from email.message import Message 4 | from imaplib import IMAP4_SSL 5 | import io 6 | from typing import Union, Dict, List, KeysView, Tuple, Optional 7 | 8 | 9 | class Struct: 10 | def __init__(self, **entries: Union[ 11 | str, datetime.datetime, Dict[str, str], list, List[Dict[str, str]] 12 | ]) -> None: ... 13 | 14 | def keys(self) -> KeysView: ... 15 | 16 | def __repr__(self) -> str: ... 17 | 18 | def decode_mail_header(value: str, default_charset: str) -> str: ... 19 | 20 | def get_mail_addresses(message: Message, header_name: str) -> List[Dict[str, str]]: ... 21 | 22 | def decode_param(param: str) -> Tuple[str, str]: ... 23 | 24 | def parse_attachment(message_part: Message) -> Optional[Dict[str, Union[int, str, io.BytesIO]]]: ... 25 | 26 | def decode_content(message: Message) -> str: ... 27 | 28 | def fetch_email_by_uid(uid: bytes, connection: IMAP4_SSL, parser_policy: Optional[Policy]) -> Struct: ... 29 | raw_headers: bytes 30 | raw_email: bytes 31 | 32 | def parse_flags(headers: str) -> Union[list, List[bytes]]: ... 33 | 34 | def parse_email(raw_email: bytes, policy: Optional[Policy]) -> Struct: ... 35 | -------------------------------------------------------------------------------- /imbox/query.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from imbox.utils import date_to_date_text 4 | 5 | 6 | def build_search_query(imap_attribute_lookup, **kwargs): 7 | query = [] 8 | for name, value in kwargs.items(): 9 | if value is not None: 10 | if isinstance(value, datetime.date): 11 | value = date_to_date_text(value) 12 | if isinstance(value, str) and '"' in value: 13 | value = value.replace('"', "'") 14 | query.append(imap_attribute_lookup[name].format(value)) 15 | 16 | if query: 17 | return " ".join(query) 18 | 19 | return "(ALL)" 20 | -------------------------------------------------------------------------------- /imbox/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | from imaplib import Time2Internaldate 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def str_encode(value='', encoding=None, errors='strict'): 8 | logger.debug("Encode str {value} with encoding {encoding} and errors {errors}".format( 9 | value=value, 10 | encoding=encoding, 11 | errors=errors)) 12 | return str(value, encoding, errors) 13 | 14 | 15 | def str_decode(value='', encoding=None, errors='strict'): 16 | if isinstance(value, str): 17 | return bytes(value, encoding, errors).decode('utf-8') 18 | elif isinstance(value, bytes): 19 | return value.decode(encoding or 'utf-8', errors=errors) 20 | else: 21 | raise TypeError("Cannot decode '{}' object".format(value.__class__)) 22 | 23 | 24 | def date_to_date_text(date): 25 | """Return a date in the RFC 3501 date-text syntax""" 26 | tzutc = datetime.timezone.utc 27 | dt = datetime.datetime.combine(date, datetime.time.min, tzutc) 28 | return Time2Internaldate(dt)[1:12] 29 | -------------------------------------------------------------------------------- /imbox/utils.pyi: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | 4 | def str_encode(value: Union[str, bytes], encoding: Optional[str], errors: str) -> str: ... 5 | 6 | def str_decode(value: Union[str, bytes], encoding: Optional[str], errors: str) -> Union[str, bytes]: ... 7 | -------------------------------------------------------------------------------- /imbox/vendors/__init__.py: -------------------------------------------------------------------------------- 1 | from imbox.vendors.gmail import GmailMessages 2 | 3 | vendors = [GmailMessages] 4 | 5 | hostname_vendorname_dict = {vendor.hostname: vendor.name for vendor in vendors} 6 | name_authentication_string_dict = {vendor.name: vendor.authentication_error_message for vendor in vendors} 7 | 8 | __all__ = [v.__name__ for v in vendors] 9 | 10 | __all__ += ['hostname_vendorname_dict', 11 | 'name_authentication_string_dict'] 12 | -------------------------------------------------------------------------------- /imbox/vendors/__init__.pyi: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional 2 | 3 | from imbox.messages import Messages 4 | 5 | vendors: List[Messages] 6 | 7 | hostname_vendorname_dict: Dict[str, str] 8 | name_authentication_string_dict: Dict[str, Optional[str]] 9 | 10 | -------------------------------------------------------------------------------- /imbox/vendors/gmail.py: -------------------------------------------------------------------------------- 1 | from imbox.messages import Messages 2 | from imbox.vendors.helpers import merge_two_dicts 3 | 4 | 5 | class GmailMessages(Messages): 6 | authentication_error_message = ('If you\'re not using an app-specific password, grab one here: ' 7 | 'https://myaccount.google.com/apppasswords') 8 | hostname = 'imap.gmail.com' 9 | name = 'gmail' 10 | FOLDER_LOOKUP = { 11 | 12 | 'all_mail': '"[Gmail]/All Mail"', 13 | 'all': '"[Gmail]/All Mail"', 14 | 'all mail': '"[Gmail]/All Mail"', 15 | 'sent': '"[Gmail]/Sent Mail"', 16 | 'sent mail': '"[Gmail]/Sent Mail"', 17 | 'sent_mail': '"[Gmail]/Sent Mail"', 18 | 'drafts': '"[Gmail]/Drafts"', 19 | 'important': '"[Gmail]/Important"', 20 | 'spam': '"[Gmail]/Spam"', 21 | 'starred': '"[Gmail]/Starred"', 22 | 'trash': '"[Gmail]/Trash"', 23 | } 24 | 25 | GMAIL_IMAP_ATTRIBUTE_LOOKUP_DIFF = { 26 | 'subject': '(X-GM-RAW "subject:\'{}\'")', 27 | 'label': '(X-GM-LABELS "{}")', 28 | 'raw': '(X-GM-RAW "{}")' 29 | } 30 | 31 | def __init__(self, 32 | connection, 33 | parser_policy, 34 | **kwargs): 35 | 36 | self.IMAP_ATTRIBUTE_LOOKUP = merge_two_dicts(self.IMAP_ATTRIBUTE_LOOKUP, 37 | self.GMAIL_IMAP_ATTRIBUTE_LOOKUP_DIFF) 38 | 39 | super().__init__(connection, parser_policy, **kwargs) 40 | -------------------------------------------------------------------------------- /imbox/vendors/gmail.pyi: -------------------------------------------------------------------------------- 1 | import datetime 2 | from email._policybase import Policy 3 | from imaplib import IMAP4, IMAP4_SSL 4 | from typing import Union 5 | 6 | from imbox.messages import Messages 7 | 8 | 9 | class GmailMessages(Messages): 10 | 11 | def __init__(self, 12 | connection: Union[IMAP4, IMAP4_SSL], 13 | parser_policy: Policy, 14 | **kwargs: Union[bool, str, datetime.date]) -> None: ... 15 | -------------------------------------------------------------------------------- /imbox/vendors/helpers.py: -------------------------------------------------------------------------------- 1 | 2 | def merge_two_dicts(x, y): 3 | """from https://stackoverflow.com/a/26853961/4386191""" 4 | z = x.copy() # start with x's keys and values 5 | z.update(y) # modifies z with y's keys and values & returns None 6 | return z 7 | -------------------------------------------------------------------------------- /imbox/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.9.9" 2 | VERSION = __version__.split('.') 3 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "chardet" 3 | version = "5.0.0" 4 | description = "Universal encoding detector for Python 3" 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.6" 8 | 9 | [metadata] 10 | lock-version = "1.1" 11 | python-versions = "^3.7" 12 | content-hash = "b14a41bb82d31939506454704f36f9d5c04663623cd4a6f47152577428408e3e" 13 | 14 | [metadata.files] 15 | chardet = [ 16 | {file = "chardet-5.0.0-py3-none-any.whl", hash = "sha256:d3e64f022d254183001eccc5db4040520c0f23b1a3f33d6413e099eb7f126557"}, 17 | {file = "chardet-5.0.0.tar.gz", hash = "sha256:0368df2bfd78b5fc20572bb4e9bb7fb53e2c094f60ae9993339e8671d0afb8aa"}, 18 | ] 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "imbox" 3 | version = "0.9.9" 4 | description = "" 5 | authors = ["Martin Rusev "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.7" 10 | chardet = "^5.0.0" 11 | 12 | 13 | [build-system] 14 | requires = ["poetry-core"] 15 | build-backend = "poetry.core.masonry.api" 16 | -------------------------------------------------------------------------------- /tests/8422.msg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martinrusev/imbox/7ec744ba4698c2aaa43c599e6463a9ece25f45e7/tests/8422.msg -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martinrusev/imbox/7ec744ba4698c2aaa43c599e6463a9ece25f45e7/tests/__init__.py -------------------------------------------------------------------------------- /tests/parser_tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from imbox.parser import * 3 | 4 | import os 5 | import sys 6 | if sys.version_info.minor < 3: 7 | SMTP = False 8 | else: 9 | from email.policy import SMTP 10 | 11 | 12 | TEST_DIR = os.path.dirname(os.path.abspath(__file__)) 13 | 14 | 15 | raw_email = """Delivered-To: johndoe@gmail.com 16 | X-Originating-Email: [martin@amon.cx] 17 | Message-ID: 18 | Return-Path: martin@amon.cx 19 | Date: Tue, 30 Jul 2013 15:56:29 +0300 20 | From: Martin Rusev 21 | MIME-Version: 1.0 22 | To: John Doe 23 | Subject: Test email - no attachment 24 | Content-Type: multipart/alternative; 25 | boundary="------------080505090108000500080106" 26 | X-OriginalArrivalTime: 30 Jul 2013 12:56:43.0604 (UTC) FILETIME=[3DD52140:01CE8D24] 27 | 28 | --------------080505090108000500080106 29 | Content-Type: text/plain; charset="ISO-8859-1"; format=flowed 30 | Content-Transfer-Encoding: 7bit 31 | 32 | Hi, this is a test email with no attachments 33 | 34 | --------------080505090108000500080106 35 | Content-Type: text/html; charset="ISO-8859-1" 36 | Content-Transfer-Encoding: 7bit 37 | 38 | 39 | 41 | Hi, this is a test email with no attachments
42 | 43 | 44 | 45 | --------------080505090108000500080106-- 46 | """ 47 | 48 | raw_email_encoded = b"""Delivered-To: receiver@example.com 49 | Return-Path: 50 | Date: Sat, 26 Mar 2016 13:55:30 +0300 (FET) 51 | From: sender@example.com 52 | To: receiver@example.com 53 | Message-ID: <811170233.1296.1345983710614.JavaMail.bris@BRIS-AS-NEW.site> 54 | Subject: =?ISO-8859-5?B?suvf2OHa0CDf3iDa0ODi1Q==?= 55 | MIME-Version: 1.0 56 | Content-Type: multipart/mixed; 57 | boundary="----=_Part_1295_1644105626.1458989730614" 58 | 59 | ------=_Part_1295_1644105626.1458989730614 60 | Content-Type: text/html; charset=ISO-8859-5 61 | Content-Transfer-Encoding: quoted-printable 62 | 63 | =B2=EB=DF=D8=E1=DA=D0 =DF=DE =DA=D0=E0=E2=D5 1234 64 | ------=_Part_1295_1644105626.1458989730614-- 65 | """ 66 | 67 | raw_email_encoded_needs_refolding = b"""Delivered-To: receiver@example.com 68 | Return-Path: 69 | Date: Sat, 26 Mar 2016 13:55:30 +0300 (FET) 70 | From: sender@example.com 71 | To: "Receiver" , "Second\r\n Receiver" 72 | Message-ID: <811170233.1296.1345983710614.JavaMail.bris@BRIS-AS-NEW.site> 73 | Subject: =?ISO-8859-5?B?suvf2OHa0CDf3iDa0ODi1Q==?= 74 | MIME-Version: 1.0 75 | Content-Type: multipart/mixed; 76 | boundary="----=_Part_1295_1644105626.1458989730614" 77 | 78 | ------=_Part_1295_1644105626.1458989730614 79 | Content-Type: text/html; charset=ISO-8859-5 80 | Content-Transfer-Encoding: quoted-printable 81 | 82 | =B2=EB=DF=D8=E1=DA=D0 =DF=DE =DA=D0=E0=E2=D5 1234 83 | ------=_Part_1295_1644105626.1458989730614-- 84 | """ 85 | 86 | 87 | raw_email_encoded_multipart = b"""Delivered-To: receiver@example.com 88 | Return-Path: 89 | Date: Tue, 08 Aug 2017 08:15:11 -0700 90 | From: 91 | To: interviews+347243@gethappie.me 92 | Message-Id: <20170808081511.2b876c018dd94666bcc18e28cf079afb.99766f164b.wbe@email24.godaddy.com> 93 | Subject: RE: Kari, are you open to this? 94 | Mime-Version: 1.0 95 | Content-Type: multipart/related; 96 | boundary="=_7c18e0b95b772890a22ed6c0f810a434" 97 | 98 | --=_7c18e0b95b772890a22ed6c0f810a434 99 | Content-Transfer-Encoding: quoted-printable 100 | Content-Type: text/html; charset="utf-8" 101 | 102 |
Hi Richie,
104 | --=_7c18e0b95b772890a22ed6c0f810a434 105 | Content-Transfer-Encoding: base64 106 | Content-Type: image/jpeg; charset=binary; 107 | name="sigimg0"; 108 | Content-Disposition: inline; 109 | filename="sigimg0"; 110 | 111 | /9j/4AAQSkZJRgABAQAAAQABAAD//gA+Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcg 112 | jt0JaKhjm3xq23GR60UuZBZn/9k= 113 | --=_7c18e0b95b772890a22ed6c0f810a434 114 | Content-Transfer-Encoding: base64 115 | Content-Type: image/jpeg; charset=binary; 116 | name="sigimg1"; 117 | Content-Disposition: inline; 118 | filename="sigimg1"; 119 | 120 | /9j/4AAQSkZJRgABAQAAAQABAAD//gA+Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcg 121 | SlBFRyB2NjIpLCBkZWZhdWx0IHF1YWxpdHkK/9sAQwAIBgYHBgUIBwcHCQkICgwUDQwLCwwZEhMP 122 | ooooA//Z 123 | --=_7c18e0b95b772890a22ed6c0f810a434-- 124 | 125 | """ 126 | 127 | 128 | raw_email_encoded_bad_multipart = b"""Delivered-To: receiver@example.com 129 | Return-Path: 130 | From: sender@example.com 131 | To: "Receiver" , "Second\r\n Receiver" 132 | Subject: Re: Looking to connect with you... 133 | Date: Thu, 20 Apr 2017 15:32:52 +0000 134 | Message-ID: 135 | Content-Type: multipart/related; 136 | boundary="_004_BN6PR16MB179579288933D60C4016D078C31B0BN6PR16MB1795namp_"; 137 | type="multipart/alternative" 138 | MIME-Version: 1.0 139 | --_004_BN6PR16MB179579288933D60C4016D078C31B0BN6PR16MB1795namp_ 140 | Content-Type: multipart/alternative; 141 | boundary="_000_BN6PR16MB179579288933D60C4016D078C31B0BN6PR16MB1795namp_" 142 | --_000_BN6PR16MB179579288933D60C4016D078C31B0BN6PR16MB1795namp_ 143 | Content-Type: text/plain; charset="utf-8" 144 | Content-Transfer-Encoding: base64 145 | SGkgRGFuaWVsbGUsDQoNCg0KSSBhY3R1YWxseSBhbSBoYXBweSBpbiBteSBjdXJyZW50IHJvbGUs 146 | Y3J1aXRlciB8IENoYXJsb3R0ZSwgTkMNClNlbnQgdmlhIEhhcHBpZQ0KDQoNCg== 147 | --_000_BN6PR16MB179579288933D60C4016D078C31B0BN6PR16MB1795namp_ 148 | Content-Type: text/html; charset="utf-8" 149 | Content-Transfer-Encoding: base64 150 | PGh0bWw+DQo8aGVhZD4NCjxtZXRhIGh0dHAtZXF1aXY9IkNvbnRlbnQtVHlwZSIgY29udGVudD0i 151 | CjwvZGl2Pg0KPC9kaXY+DQo8L2JvZHk+DQo8L2h0bWw+DQo= 152 | --_000_BN6PR16MB179579288933D60C4016D078C31B0BN6PR16MB1795namp_-- 153 | --_004_BN6PR16MB179579288933D60C4016D078C31B0BN6PR16MB1795namp_ 154 | Content-Type: image/png; name="=?utf-8?B?T3V0bG9va0Vtb2ppLfCfmIoucG5n?=" 155 | Content-Description: =?utf-8?B?T3V0bG9va0Vtb2ppLfCfmIoucG5n?= 156 | Content-Disposition: inline; 157 | filename="=?utf-8?B?T3V0bG9va0Vtb2ppLfCfmIoucG5n?="; size=488; 158 | creation-date="Thu, 20 Apr 2017 15:32:52 GMT"; 159 | modification-date="Thu, 20 Apr 2017 15:32:52 GMT" 160 | Content-ID: <254962e2-f05c-40d1-aa11-0d34671b056c> 161 | Content-Transfer-Encoding: base64 162 | iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ 163 | cvED9AIR3TCAAAMAqh+p+YMVeBQAAAAASUVORK5CYII= 164 | --_004_BN6PR16MB179579288933D60C4016D078C31B0BN6PR16MB1795namp_-- 165 | """ 166 | 167 | 168 | raw_email_encoded_another_bad_multipart = b"""Delivered-To: receiver@example.com 169 | Return-Path: 170 | Mime-Version: 1.0 171 | Date: Wed, 22 Mar 2017 15:21:55 -0500 172 | Message-ID: <58D29693.192A.0075.1@wimort.com> 173 | Subject: Re: Reaching Out About Peoples Home Equity 174 | From: sender@example.com 175 | To: receiver@example.com 176 | Content-Type: multipart/alternative; boundary="____NOIBTUQXSYRVOOAFLCHY____" 177 | 178 | 179 | --____NOIBTUQXSYRVOOAFLCHY____ 180 | Content-Type: text/plain; charset=iso-8859-15 181 | Content-Transfer-Encoding: quoted-printable 182 | Content-Disposition: inline; 183 | modification-date="Wed, 22 Mar 2017 15:21:55 -0500" 184 | 185 | Chloe, 186 | 187 | --____NOIBTUQXSYRVOOAFLCHY____ 188 | Content-Type: multipart/related; boundary="____XTSWHCFJMONXSVGPVDLY____" 189 | 190 | 191 | --____XTSWHCFJMONXSVGPVDLY____ 192 | Content-Type: text/html; charset=iso-8859-15 193 | Content-Transfer-Encoding: quoted-printable 194 | Content-Disposition: inline; 195 | modification-date="Wed, 22 Mar 2017 15:21:55 -0500" 196 | 197 | 198 | 201 |
Chloe,
202 | 205 | --____XTSWHCFJMONXSVGPVDLY____ 206 | Content-ID: 207 | Content-Type: image/gif 208 | Content-Transfer-Encoding: base64 209 | 210 | R0lGODlhHgHCAPf/AIOPr9GvT7SFcZZjVTEuMLS1tZKUlJN0Znp4eEA7PV1aWvz8+8V6Zl1BNYxX 211 | HvOZ1/zmOd95agUEADs= 212 | --____XTSWHCFJMONXSVGPVDLY____ 213 | Content-ID: 214 | Content-Type: image/xxx 215 | Content-Transfer-Encoding: base64 216 | 217 | R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw== 218 | --____XTSWHCFJMONXSVGPVDLY____-- 219 | 220 | --____NOIBTUQXSYRVOOAFLCHY____-- 221 | """ 222 | 223 | 224 | raw_email_with_trailing_semicolon_to_disposition_content = b"""Delivered-To: receiver@example.com 225 | Return-Path: 226 | Mime-Version: 1.0 227 | Date: Wed, 22 Mar 2017 15:21:55 -0500 228 | Message-ID: <58D29693.192A.0075.1@wimort.com> 229 | Subject: Re: Reaching Out About Peoples Home Equity 230 | From: sender@example.com 231 | To: receiver@example.com 232 | Content-Type: multipart/alternative; boundary="____NOIBTUQXSYRVOOAFLCHY____" 233 | 234 | 235 | --____NOIBTUQXSYRVOOAFLCHY____ 236 | Content-Type: text/plain; charset=iso-8859-15 237 | Content-Transfer-Encoding: quoted-printable 238 | Content-Disposition: inline; 239 | modification-date="Wed, 22 Mar 2017 15:21:55 -0500" 240 | 241 | Hello Chloe 242 | 243 | --____NOIBTUQXSYRVOOAFLCHY____ 244 | Content-Type: multipart/related; boundary="____XTSWHCFJMONXSVGPVDLY____" 245 | 246 | 247 | --____XTSWHCFJMONXSVGPVDLY____ 248 | Content-Type: text/html; charset=iso-8859-15 249 | Content-Transfer-Encoding: quoted-printable 250 | Content-Disposition: inline; 251 | modification-date="Wed, 22 Mar 2017 15:21:55 -0500" 252 | 253 | 254 | 255 |
Hello Chloe
256 | 257 | 258 | --____XTSWHCFJMONXSVGPVDLY____ 259 | Content-Type: application/octet-stream; name="abc.xyz" 260 | Content-Description: abc.xyz 261 | Content-Disposition: attachment; filename="abc.xyz"; 262 | Content-Transfer-Encoding: base64 263 | 264 | R0lGODlhHgHCAPf/AIOPr9GvT7SFcZZjVTEuMLS1tZKUlJN0Znp4eEA7PV1aWvz8+8V6Zl1BNYxX 265 | HvOZ1/zmOd95agUEADs= 266 | --____XTSWHCFJMONXSVGPVDLY____ 267 | Content-ID: 268 | Content-Type: image/xxx 269 | Content-Transfer-Encoding: base64 270 | 271 | R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw== 272 | --____XTSWHCFJMONXSVGPVDLY____-- 273 | 274 | --____NOIBTUQXSYRVOOAFLCHY____-- 275 | """ 276 | 277 | raw_email_with_long_filename_attachment = b"""Delivered-To: receiver@example.com 278 | Return-Path: 279 | Mime-Version: 1.0 280 | Date: Wed, 22 Mar 2017 15:21:55 -0500 281 | Message-ID: <58D29693.192A.0075.1@wimort.com> 282 | Subject: Re: Reaching Out About Peoples Home Equity 283 | From: sender@example.com 284 | To: receiver@example.com 285 | Content-Type: multipart/alternative; boundary="____NOIBTUQXSYRVOOAFLCHY____" 286 | 287 | 288 | --____NOIBTUQXSYRVOOAFLCHY____ 289 | Content-Type: text/plain; charset=iso-8859-15 290 | Content-Transfer-Encoding: quoted-printable 291 | Content-Disposition: inline; 292 | modification-date="Wed, 22 Mar 2017 15:21:55 -0500" 293 | 294 | Hello Chloe 295 | 296 | --____NOIBTUQXSYRVOOAFLCHY____ 297 | Content-Type: multipart/related; boundary="____XTSWHCFJMONXSVGPVDLY____" 298 | 299 | 300 | --____XTSWHCFJMONXSVGPVDLY____ 301 | Content-Type: text/html; charset=iso-8859-15 302 | Content-Transfer-Encoding: quoted-printable 303 | Content-Disposition: inline; 304 | modification-date="Wed, 22 Mar 2017 15:21:55 -0500" 305 | 306 | 307 | 308 |
Hello Chloe
309 | 310 | 311 | --____XTSWHCFJMONXSVGPVDLY____ 312 | Content-Type: application/octet-stream; name="abc.xyz" 313 | Content-Description: abcefghijklmnopqrstuvwxyz01234567890abcefghijklmnopqrstuvwxyz01234567890abcefghijklmnopqrstuvwxyz01234567890.xyz 314 | Content-Disposition: attachment; filename*0="abcefghijklmnopqrstuvwxyz01234567890abcefghijklmnopqrstuvwxyz01234567890abce"; filename*1="fghijklmnopqrstuvwxyz01234567890.xyz"; 315 | Content-Transfer-Encoding: base64 316 | 317 | R0lGODlhHgHCAPf/AIOPr9GvT7SFcZZjVTEuMLS1tZKUlJN0Znp4eEA7PV1aWvz8+8V6Zl1BNYxX 318 | HvOZ1/zmOd95agUEADs= 319 | --____XTSWHCFJMONXSVGPVDLY____ 320 | Content-ID: 321 | Content-Type: image/xxx 322 | Content-Transfer-Encoding: base64 323 | 324 | R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw== 325 | --____XTSWHCFJMONXSVGPVDLY____-- 326 | 327 | --____NOIBTUQXSYRVOOAFLCHY____-- 328 | """ 329 | 330 | raw_email_encoded_encoding_charset_contains_a_minus = b"""Delivered-To: 331 | Return-Path: 332 | Message-ID: <74836CF6FF9B1965927DE7EE8A087483@NXOFGRQFQW2> 333 | From: 334 | To: 335 | Subject: Salut, mon cher. 336 | Date: 30 May 2018 22:47:37 +0200 337 | MIME-Version: 1.0 338 | Content-Type: multipart/alternative; 339 | boundary="----=_NextPart_000_0038_01D3F85C.02934C4A" 340 | 341 | ------=_NextPart_000_0038_01D3F85C.02934C4A 342 | Content-Type: text/plain; 343 | charset="cp-850" 344 | Content-Transfer-Encoding: quoted-printable 345 | 346 | spam here 347 | 348 | 349 | cliquez ici 350 | ------=_NextPart_000_0038_01D3F85C.02934C4A 351 | Content-Type: text/html; 352 | charset="cp-850" 353 | Content-Transfer-Encoding: quoted-printable 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | spam here
363 |
364 | cliquez = 365 | ici
366 | ------=_NextPart_000_0038_01D3F85C.02934C4A-- 367 | """ 368 | 369 | raw_email_attachment_only = """Delivered-To: johndoe@gmail.com 370 | X-Originating-Email: [martin@amon.cx] 371 | Message-ID: 372 | Return-Path: martin@amon.cx 373 | Date: Tue, 30 Jul 2013 15:56:29 +0300 374 | From: Martin Rusev 375 | MIME-Version: 1.0 376 | To: John Doe 377 | Subject: Test email - only pdf in body 378 | Content-Type: application/pdf; 379 | name="=?utf-8?B?YV9sb25nX2ZpbGVuYW1lX3dpdGhfc3BlY2lhbF9jaGFyX8O2w6Rf?= 380 | =?utf-8?B?LTAxX28ucGRm?=" 381 | Content-Transfer-Encoding: base64 382 | Content-Disposition: attachment; 383 | filename="=?utf-8?B?YV9sb25nX2ZpbGVuYW1lX3dpdGhfc3BlY2lhbF9jaGFyX8O2w6Rf?= 384 | =?utf-8?B?LTAxX28ucGRm?=" 385 | 386 | JVBERi0xLjQKJcOiw6PDj8OTCjUgMCBvYmoKPDwKL0xlbmd0aCAxCj4+CnN0cmVhbQogCmVuZHN0 387 | cmVhbQplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovTWVkaWFCb3ggWzAgMCA2MTIgNzky 388 | XQovUmVzb3VyY2VzIDw8Cj4+Ci9Db250ZW50cyA1IDAgUgovUGFyZW50IDIgMCBSCj4+CmVuZG9i 389 | agoyIDAgb2JqCjw8Ci9UeXBlIC9QYWdlcwovS2lkcyBbNCAwIFJdCi9Db3VudCAxCj4+CmVuZG9i 390 | agoxIDAgb2JqCjw8Ci9UeXBlIC9DYXRhbG9nCi9QYWdlcyAyIDAgUgo+PgplbmRvYmoKMyAwIG9i 391 | ago8PAovQ3JlYXRvciAoUERGIENyZWF0b3IgaHR0cDovL3d3dy5wZGYtdG9vbHMuY29tKQovQ3Jl 392 | YXRpb25EYXRlIChEOjIwMTUwNzAxMTEyNDQ3KzAyJzAwJykKL01vZERhdGUgKEQ6MjAyMjA2MDcx 393 | ODM2MDIrMDInMDAnKQovUHJvZHVjZXIgKDMtSGVpZ2h0c1wyMjIgUERGIE9wdGltaXphdGlvbiBT 394 | aGVsbCA2LjAuMC4wIFwoaHR0cDovL3d3dy5wZGYtdG9vbHMuY29tXCkpCj4+CmVuZG9iagp4cmVm 395 | CjAgNgowMDAwMDAwMDAwIDY1NTM1IGYKMDAwMDAwMDIyNiAwMDAwMCBuCjAwMDAwMDAxNjkgMDAw 396 | MDAgbgowMDAwMDAwMjc1IDAwMDAwIG4KMDAwMDAwMDA2NSAwMDAwMCBuCjAwMDAwMDAwMTUgMDAw 397 | MDAgbgp0cmFpbGVyCjw8Ci9TaXplIDYKL1Jvb3QgMSAwIFIKL0luZm8gMyAwIFIKL0lEIFs8MUMz 398 | NTAwQ0E5RjcyMzJCOTdFMEVGM0Y3ODlFOEI3RjI+IDwyNTRDOEQxNTNGNjU1RDQ5OTQ1RUFENjhE 399 | ODAxRTAxMT5dCj4+CnN0YXJ0eHJlZgo1MDUKJSVFT0Y= 400 | """ 401 | 402 | class TestParser(unittest.TestCase): 403 | 404 | def test_parse_email(self): 405 | parsed_email = parse_email(raw_email) 406 | 407 | self.assertEqual(raw_email, parsed_email.raw_email) 408 | self.assertEqual('Test email - no attachment', parsed_email.subject) 409 | self.assertEqual('Tue, 30 Jul 2013 15:56:29 +0300', parsed_email.date) 410 | self.assertEqual('', parsed_email.message_id) 411 | 412 | def test_parse_email_encoded(self): 413 | parsed_email = parse_email(raw_email_encoded) 414 | 415 | self.assertEqual('Выписка по карте', parsed_email.subject) 416 | self.assertEqual('Выписка по карте 1234', parsed_email.body['html'][0]) 417 | 418 | def test_parse_email_invalid_unicode(self): 419 | parsed_email = parse_email(open(os.path.join(TEST_DIR, '8422.msg'), 'rb').read()) 420 | self.assertEqual("Following up Re: Looking to connect, let's schedule a call!", parsed_email.subject) 421 | 422 | def test_parse_email_inline_body(self): 423 | parsed_email = parse_email(raw_email_encoded_another_bad_multipart) 424 | self.assertEqual("Re: Reaching Out About Peoples Home Equity", parsed_email.subject) 425 | self.assertTrue(parsed_email.body['plain']) 426 | self.assertTrue(parsed_email.body['html']) 427 | 428 | def test_parse_email_multipart(self): 429 | parsed_email = parse_email(raw_email_encoded_multipart) 430 | self.assertEqual("RE: Kari, are you open to this?", parsed_email.subject) 431 | 432 | def test_parse_email_bad_multipart(self): 433 | parsed_email = parse_email(raw_email_encoded_bad_multipart) 434 | self.assertEqual("Re: Looking to connect with you...", parsed_email.subject) 435 | 436 | def test_parse_email_ignores_header_casing(self): 437 | self.assertEqual('one', parse_email('Message-ID: one').message_id) 438 | self.assertEqual('one', parse_email('Message-Id: one').message_id) 439 | self.assertEqual('one', parse_email('Message-id: one').message_id) 440 | self.assertEqual('one', parse_email('message-id: one').message_id) 441 | 442 | def test_parse_attachment(self): 443 | parsed_email = parse_email(raw_email_with_trailing_semicolon_to_disposition_content) 444 | self.assertEqual(1, len(parsed_email.attachments)) 445 | attachment = parsed_email.attachments[0] 446 | self.assertEqual('application/octet-stream', attachment['content-type']) 447 | self.assertEqual(71, attachment['size']) 448 | self.assertEqual('abc.xyz', attachment['filename']) 449 | self.assertTrue(attachment['content']) 450 | 451 | def test_parse_attachment_with_long_filename(self): 452 | parsed_email = parse_email(raw_email_with_long_filename_attachment) 453 | self.assertEqual(1, len(parsed_email.attachments)) 454 | attachment = parsed_email.attachments[0] 455 | self.assertEqual('application/octet-stream', attachment['content-type']) 456 | self.assertEqual(71, attachment['size']) 457 | self.assertEqual('abcefghijklmnopqrstuvwxyz01234567890abcefghijklmnopqrstuvwxyz01234567890abcefghijklmnopqrstuvwxyz01234567890.xyz', attachment['filename']) 458 | self.assertTrue(attachment['content']) 459 | 460 | def test_parse_email_single_attachment(self): 461 | parsed_email = parse_email(raw_email_attachment_only) 462 | self.assertEqual(1, len(parsed_email.attachments)) 463 | attachment = parsed_email.attachments[0] 464 | self.assertEqual('application/pdf', attachment['content-type']) 465 | self.assertEqual(773, attachment['size']) 466 | self.assertEqual('a_long_filename_with_special_char_öä_-01_o.pdf', attachment['filename']) 467 | self.assertTrue(attachment['content']) 468 | 469 | def test_parse_email_accept_if_declared_charset_contains_a_minus_character(self): 470 | parsed_email = parse_email(raw_email_encoded_encoding_charset_contains_a_minus) 471 | self.assertEqual("Salut, mon cher.", parsed_email.subject) 472 | self.assertTrue(parsed_email.body['plain']) 473 | self.assertTrue(parsed_email.body['html']) 474 | 475 | # TODO - Complete the test suite 476 | def test_decode_mail_header(self): 477 | pass 478 | 479 | def test_get_mail_addresses(self): 480 | 481 | to_message_object = email.message_from_string("To: John Doe ") 482 | self.assertEqual([{'email': 'johndoe@gmail.com', 'name': 'John Doe'}], get_mail_addresses(to_message_object, 'to')) 483 | 484 | from_message_object = email.message_from_string("From: John Smith ") 485 | self.assertEqual([{'email': 'johnsmith@gmail.com', 'name': 'John Smith'}], get_mail_addresses(from_message_object, 'from')) 486 | 487 | invalid_encoding_in_from_message_object = email.message_from_string("From: =?UTF-8?Q?C=E4cilia?= ") 488 | self.assertEqual([{'email': 'caciliahxg827m@example.org', 'name': 'C�cilia'}], get_mail_addresses(invalid_encoding_in_from_message_object, 'from')) 489 | 490 | def test_parse_email_with_policy(self): 491 | if not SMTP: 492 | return 493 | 494 | message_object = email.message_from_bytes( 495 | raw_email_encoded_needs_refolding, 496 | policy=SMTP.clone(refold_source='all') 497 | ) 498 | 499 | self.assertEqual([ 500 | {'email': 'receiver@example.com', 'name': 'Receiver'}, 501 | {'email': 'recipient@example.com', 'name': 'Second Receiver'} 502 | ], get_mail_addresses(message_object, 'to')) 503 | -------------------------------------------------------------------------------- /tests/query_tests.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | import unittest 3 | 4 | from imbox.query import build_search_query 5 | from imbox.messages import Messages 6 | from imbox.vendors.helpers import merge_two_dicts 7 | from imbox.vendors.gmail import GmailMessages 8 | 9 | IMAP_ATTRIBUTE_LOOKUP = Messages.IMAP_ATTRIBUTE_LOOKUP 10 | GMAIL_ATTRIBUTE_LOOKUP = merge_two_dicts(IMAP_ATTRIBUTE_LOOKUP, 11 | GmailMessages.GMAIL_IMAP_ATTRIBUTE_LOOKUP_DIFF) 12 | 13 | 14 | class TestQuery(unittest.TestCase): 15 | 16 | def test_all(self): 17 | 18 | res = build_search_query(IMAP_ATTRIBUTE_LOOKUP) 19 | self.assertEqual(res, "(ALL)") 20 | 21 | def test_subject(self): 22 | 23 | res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, subject='hi') 24 | self.assertEqual(res, '(SUBJECT "hi")') 25 | 26 | res = build_search_query(GMAIL_ATTRIBUTE_LOOKUP, subject='hi') 27 | self.assertEqual(res, '(X-GM-RAW "subject:\'hi\'")') 28 | 29 | def test_unread(self): 30 | 31 | res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, unread=True) 32 | self.assertEqual(res, "(UNSEEN)") 33 | 34 | def test_unflagged(self): 35 | 36 | res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, unflagged=True) 37 | self.assertEqual(res, "(UNFLAGGED)") 38 | 39 | def test_flagged(self): 40 | 41 | res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, flagged=True) 42 | self.assertEqual(res, "(FLAGGED)") 43 | 44 | def test_sent_from(self): 45 | 46 | res = build_search_query( 47 | IMAP_ATTRIBUTE_LOOKUP, sent_from='test@example.com') 48 | self.assertEqual(res, '(FROM "test@example.com")') 49 | 50 | def test_sent_to(self): 51 | 52 | res = build_search_query( 53 | IMAP_ATTRIBUTE_LOOKUP, sent_to='test@example.com') 54 | self.assertEqual(res, '(TO "test@example.com")') 55 | 56 | def test_date__gt(self): 57 | 58 | res = build_search_query( 59 | IMAP_ATTRIBUTE_LOOKUP, date__gt=date(2014, 12, 31)) 60 | self.assertEqual(res, '(SINCE "31-Dec-2014")') 61 | 62 | def test_date__lt(self): 63 | 64 | res = build_search_query( 65 | IMAP_ATTRIBUTE_LOOKUP, date__lt=date(2014, 1, 1)) 66 | self.assertEqual(res, '(BEFORE "01-Jan-2014")') 67 | 68 | def test_date__on(self): 69 | res = build_search_query( 70 | IMAP_ATTRIBUTE_LOOKUP, date__on=date(2014, 1, 1)) 71 | self.assertEqual(res, '(ON "01-Jan-2014")') 72 | 73 | def test_uid__range(self): 74 | res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, uid__range='1000:*') 75 | self.assertEqual(res, '(UID 1000:*)') 76 | 77 | def test_text(self): 78 | res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, text='mail body') 79 | self.assertEqual(res, '(TEXT "mail body")') 80 | 81 | def test_gmail_raw(self): 82 | res = build_search_query(GMAIL_ATTRIBUTE_LOOKUP, raw='has:attachment subject:"hey"') 83 | self.assertEqual(res, '(X-GM-RAW "has:attachment subject:\'hey\'")') 84 | 85 | def test_gmail_label(self): 86 | res = build_search_query(GMAIL_ATTRIBUTE_LOOKUP, label='finance') 87 | self.assertEqual(res, '(X-GM-LABELS "finance")') 88 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py33,py34,py35,py36 3 | 4 | [testenv] 5 | deps=nose 6 | commands=nosetests -v 7 | --------------------------------------------------------------------------------