48 Commits

Author SHA1 Message Date
12a8cd9a41 added accidentally deleted 'flagged' query option back in, fixed typo uid_range (needed two underscores), updated and filled in query tests. made dictionary combination compatible with < 3.5. 2018-10-18 15:47:20 -04:00
70b0552df4 removed test file 2018-10-18 14:33:05 -04:00
b2f82b6fb7 added support in vendors.gmail for 'label' and 'raw' searches, added these to documentation. 2018-10-18 14:30:53 -04:00
76fc7bfe8c synced with master including #153 supporting search for UID ranges 2018-10-18 13:15:22 -04:00
0f8abf3e4a Merge branch 'vendors' of https://github.com/zevaverbach/imbox into vendors 2018-10-18 13:07:27 -04:00
fee17fbc76 added .DS_Store to gitignore 2018-10-18 13:06:59 -04:00
27d19a209a moved build_search_query to Messages, refactored to use a class attribute of IMAP_ATTRIBUTE_LOOKUP, so that the vendors package can overwrite and add entries to it for, for example, Gmail's IMAP extensions. added X-GM-RAW to GmailMessages' copy of the lookup to make partial subject searches work. 2018-10-18 13:06:59 -04:00
0a94f2e4a4 added .DS_Store to gitignore 2018-10-18 13:01:59 -04:00
810e6a0cd8 moved build_search_query to Messages, refactored to use a class attribute of IMAP_ATTRIBUTE_LOOKUP, so that the vendors package can overwrite and add entries to it for, for example, Gmail's IMAP extensions. added X-GM-RAW to GmailMessages' copy of the lookup to make partial subject searches work. 2018-10-18 13:00:42 -04:00
Martin Rusev
782d3977f9 Merge pull request #153 from SkullTech/query-uid__range
Add query uid__range
2018-09-10 10:10:15 +02:00
Sumit Ghosh
c21f2ec9f7 Edit README mentioning usage of uid__range 2018-09-08 21:57:57 +05:30
Sumit Ghosh
1f0b952fbc Add query uid__range 2018-09-08 21:54:59 +05:30
Martin Rusev
d0b3fa495e Update CHANGELOG.md 2018-08-14 17:30:56 +02:00
Martin Rusev
038094e16d Update version and changelog 2018-08-14 08:24:18 -07:00
Martin Rusev
e1eb91b689 Update CHANGELOG.md 2018-08-10 12:34:53 +02:00
Martin Rusev
dbdc96f91e Merge pull request #147 from sblondon/master
fix: readd the flag Deleted to delete a message
2018-07-31 09:53:04 +02:00
Stephane Blondon
4dc43d2ba2 fix: readd the flag Deleted to delete a message 2018-07-31 13:17:18 +08:00
Martin Rusev
fd35764586 Merge pull request #146 from sblondon/remove_a_print
remove a print() call
2018-07-30 11:15:05 +02:00
Stephane Blondon
acfd162c8a remove a print() call 2018-07-30 16:17:18 +08:00
Martin Rusev
9dbf9a9071 Merge pull request #145 from sblondon/add_python_3.7
Library is compatible with python 3.7
2018-07-30 10:17:02 +02:00
Stephane Blondon
0249d59a73 Library is compatible with python 3.7 2018-07-30 16:08:27 +08:00
Martin Rusev
1ad18477f1 Merge pull request #144 from sblondon/add_vendors_directory_to_package
Add vendors directory to package
2018-07-28 17:39:18 +02:00
Martin Rusev
079569dac9 Merge pull request #143 from sblondon/master
Add a forgotten parameter in a log line
2018-07-28 17:39:01 +02:00
Stephane Blondon
7ea534fbda Add imbox/vendors/ directory to python package 2018-07-28 16:13:13 +02:00
Stephane Blondon
db30a8044f Add a forgotten parameter in a log line 2018-07-28 15:43:15 +02:00
Martin Rusev
d5a4e47904 Merge pull request #142 from sblondon/master
Remove the minus in charset if the charset is not found
2018-07-28 15:29:31 +02:00
Stephane Blondon
330a989452 Remove the minus in charset if it's not found to fix Lookup error when searching the appropriate codec 2018-07-28 12:45:19 +02:00
Martin Rusev
aa6739d91f Merge pull request #139 from zevaverbach/vendors
created vendors package
2018-07-27 22:33:10 +02:00
f82ce52220 added type hints for new stuff 2018-07-27 16:00:17 -04:00
87e42a9fdd Merge branch 'vendors' of https://github.com/zevaverbach/imbox into vendors 2018-07-27 15:41:52 -04:00
86cf1fbf9b added basic structure for vendors module, including mostly blank GmailMessages class. 'vendor' is now a kwarg for Imbox, but there's also a lookup dict created in vendors/__init__.py for 'magic' determination of the vendor from the hostname.
added `authentication_error_message` as a class attribute to `Imbox`.  If `Imbox.vendor` is not None, look for a custom authentication error string, and raise it if there's a problem logging in.  The default error value is raised if there is no custom string.

created `name_authentication_string_dict` from all subclasses of `Messages` in `vendors/__init__.py`, for the lookup mentioned above.

Implement architecture discussed in #131, deciding whether to use `Messages` or a subclass of it in `Imbox.messages` based on the `Imbox.vendor` value.  Use the `folder_lookup` dict, present on `Messages` but only filled in on its subclasses, to select the right folder based on a lowercased "standard" name given as the value of `folder`.

created a blank `folder_lookup` on `Messages`.

filled in the first vendor that subclasses `Messages`, `GmailMessages`.  It contains class attributes `authentication_error_message`, `hostname`, `name`, used for purposes described above, as well as `folder_lookup` which is a dict containing aliases, sometimes several, for Gmail's unique folder naming scheme.  These aliases are meant to be "human-friendly" for intuitive `folder` selection in calls to `Imbox.messages`.

fixes #131.

prevent too much bookkeeping in vendors/__init__.py by adding each vendor.__name__ to __all__ from the vendors list.
2018-07-27 15:41:13 -04:00
6f3cc1ab92 prevent too much bookkeeping in vendors/__init__.py by adding each vendor.__name__ to __all__ from the vendors list. 2018-07-27 14:44:35 -04:00
1fbb8511b9 added authentication_error_message as a class attribute to Imbox. If Imbox.vendor is not None, look for a custom authentication error string, and raise it if there's a problem logging in. The default error value is raised if there is no custom string.
created `name_authentication_string_dict` from all subclasses of `Messages` in `vendors/__init__.py`, for the lookup mentioned above.

Implement architecture discussed in #131, deciding whether to use `Messages` or a subclass of it in `Imbox.messages` based on the `Imbox.vendor` value.  Use the `folder_lookup` dict, present on `Messages` but only filled in on its subclasses, to select the right folder based on a lowercased "standard" name given as the value of `folder`.

created a blank `folder_lookup` on `Messages`.

filled in the first vendor that subclasses `Messages`, `GmailMessages`.  It contains class attributes `authentication_error_message`, `hostname`, `name`, used for purposes described above, as well as `folder_lookup` which is a dict containing aliases, sometimes several, for Gmail's unique folder naming scheme.  These aliases are meant to be "human-friendly" for intuitive `folder` selection in calls to `Imbox.messages`.

fixes #131.
2018-07-27 14:31:21 -04:00
Martin Rusev
adf70cbed5 Update .travis.yml 2018-07-27 14:52:06 +02:00
Martin Rusev
c04c340bb7 Update .travis.yml 2018-07-27 14:34:24 +02:00
Martin Rusev
b52d43c496 Update CHANGELOG.md 2018-07-27 10:53:58 +02:00
Martin Rusev
a8440f60ca Update CHANGELOG.md 2018-07-27 10:52:21 +02:00
Martin Rusev
c86cbe766d Update README.rst 2018-07-27 10:38:07 +02:00
Martin Rusev
16a05e15e5 Merge pull request #136 from zevaverbach/stubs
Stubs!
2018-07-27 10:37:08 +02:00
7d11b590d8 added basic structure for vendors module, including mostly blank GmailMessages class. 'vendor' is now a kwarg for Imbox, but there's also a lookup dict created in vendors/__init__.py for 'magic' determination of the vendor from the hostname. 2018-07-26 15:48:31 -04:00
dfca46886f filled in stub files 2018-07-26 15:30:02 -04:00
6d527a2f1d added type hint stubs for all modules 2018-07-26 15:24:20 -04:00
80d6104387 conflict 2018-07-26 15:08:08 -04:00
Martin Rusev
0464a5a74d Merge pull request #134 from zevaverbach/126_128_130
Added flags to messages, moved classes out of __init__.py, and documented that the inbox is where Imbox.messages are from without any folder argument.
2018-07-26 21:06:51 +02:00
2838639bfa conflict in .gitignore 2018-07-25 14:25:14 -04:00
71942a69e8 fixed var names in documentation of query keywords 2018-07-25 08:27:40 -04:00
b4cb03e145 added Pycharm directory to .gitignore 2018-07-25 08:24:05 -04:00
b53a1e6837 Added missing documentation for all supported query keyword arguments. Fixes #124. 2018-07-25 08:22:50 -04:00
23 changed files with 409 additions and 101 deletions

3
.gitignore vendored
View File

@@ -34,3 +34,6 @@ example.py
# PyCharm
.idea/
# Mac
.DS_Store

View File

@@ -1,6 +1,5 @@
language: python
python:
- "3.2"
- "3.3"
- "3.4"
- "3.5"

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"python.pythonPath": "/usr/local/bin/python3"
}

View File

@@ -1,22 +1,32 @@
## 0.9.6 (14 August 2018)
IMPROVEMENTS:
* Vendors package, adding provider specific functionality ([#139](https://github.com/martinrusev/imbox/pull/139)) - Contributed by @zevaverbach
* Type hints for every method and function ([#136](https://github.com/martinrusev/imbox/pull/136)) - Contributed by @zevaverbach
* Move all code out of __init__.py and into a separate module ([#130](https://github.com/martinrusev/imbox/pull/130)) - Contributed by @zevaverbach
* Enhance `messages' generator: ([#129](https://github.com/martinrusev/imbox/pull/129)) - Contributed by @zevaverbach
## 0.9.5 (5 December 2017)
IMPROVEMENTS:
* `date__on` support: ([#109](https://github.com/martinrusev/imbox/pull/109))
* Starttls support: ([#108](https://github.com/martinrusev/imbox/pull/108))
* Mark emails as flagged/starred: ([#107](https://github.com/martinrusev/imbox/pull/107))
* Messages filter can use date objects instead of stringified dates: ([#104](https://github.com/martinrusev/imbox/pull/104))
* Fix attachment parsing when a semicolon character ends the Content-Disposition line: ([#100](https://github.com/martinrusev/imbox/pull/100))
* Parsing - UnicecodeDecodeError() fixes: ([#96](https://github.com/martinrusev/imbox/pull/96))
* Imbox() `with` support: ([#92](https://github.com/martinrusev/imbox/pull/92))
* `date__on` support: ([#109](https://github.com/martinrusev/imbox/pull/109)) - Contributed by @balsagoth
* Starttls support: ([#108](https://github.com/martinrusev/imbox/pull/108)) - Contributed by @balsagoth
* Mark emails as flagged/starred: ([#107](https://github.com/martinrusev/imbox/pull/107)) - Contributed by @memanikantan
* Messages filter can use date objects instead of stringified dates: ([#104](https://github.com/martinrusev/imbox/pull/104)) - Contributed by @sblondon
* Fix attachment parsing when a semicolon character ends the Content-Disposition line: ([#100](https://github.com/martinrusev/imbox/pull/100)) - Contributed by @sblondon
* Parsing - UnicecodeDecodeError() fixes: ([#96](https://github.com/martinrusev/imbox/pull/96)) - Contributed by @am0z
* Imbox() `with` support: ([#92](https://github.com/martinrusev/imbox/pull/92)) - Contributed by @sblondon
## 0.9 (18 September 2017)
IMPROVEMENTS:
* Permissively Decode Emails: ([#78](https://github.com/martinrusev/imbox/pull/78))
* "With" statement for automatic cleanup/logout ([#92](https://github.com/martinrusev/imbox/pull/92))
* Permissively Decode Emails: ([#78](https://github.com/martinrusev/imbox/pull/78)) - Contributed by @AdamNiederer
* "With" statement for automatic cleanup/logout ([#92](https://github.com/martinrusev/imbox/pull/92)) - Contributed by @sblondon
@@ -24,7 +34,7 @@ IMPROVEMENTS:
IMPROVEMENTS:
* Add support for Python 3.3+ Parsing policies: ([#75](https://github.com/martinrusev/imbox/pull/75))
* Add support for Python 3.3+ Parsing policies: ([#75](https://github.com/martinrusev/imbox/pull/75)) - Contributed by @bhtucker
BACKWARDS INCOMPATIBILITIES / NOTES:
@@ -35,4 +45,4 @@ BACKWARDS INCOMPATIBILITIES / NOTES:
IMPROVEMENTS:
* ssl_context: Check SSLContext for IMAP4_SSL connections ([#69](https://github.com/martinrusev/imbox/pull/69))
* ssl_context: Check SSLContext for IMAP4_SSL connections ([#69](https://github.com/martinrusev/imbox/pull/69)) - Contributed by @dmth

View File

@@ -52,6 +52,12 @@ Usage
# Un-flagged messages
inbox_unflagged_messages = imbox.messages(unflagged=True)
# Flagged messages
flagged_messages = imbox.messages(flagged=True)
# Un-flagged messages
unflagged_messages = imbox.messages(unflagged=True)
# Messages sent FROM
inbox_messages_from = imbox.messages(sent_from='sender@example.org')
@@ -67,12 +73,18 @@ Usage
# Messages received on a specific date
inbox_messages_received_on_date = imbox.messages(date__on=datetime.date(2018, 7, 30))
# Messages from a specific folder
messages_from_folder = imbox.messages(folder='Social')
# Messages whose subjects contain a string
inbox_messages_subject_christmas = imbox.messages(subject='Christmas')
# Messages whose UID is greater than 1050
inbox_messages_uids_greater_than_1050 = imbox.messages(uid__range='1050:*')
# Messages from a specific folder
messages_in_folder_social = imbox.messages(folder='Social')
# Some of Gmail's IMAP Extensions are supported (label and raw):
all_messages_with_an_attachment_from_martin = imbox.messages(folder='all', raw='from:martin@amon.cx has:attachment')
all_messages_labeled_finance = imbox.messages(folder='all', label='finance')
for uid, message in all_inbox_messages:
# Every message is an object with the following keys

View File

@@ -1,8 +1,9 @@
from imbox.imbox import Imbox
__version_info__ = (0, 9, 5)
__version_info__ = (0, 9, 6)
__version__ = '.'.join([str(x) for x in __version_info__])
__all__ = ['Imbox']

13
imbox/imap.pyi Normal file
View File

@@ -0,0 +1,13 @@
from imaplib import IMAP4, IMAP4_SSL
from ssl import SSLContext
from typing import Optional, Union, Tuple, List
class ImapTransport:
def __init__(self, hostname: str, port: Optional[int], ssl: bool,
ssl_context: Optional[SSLContext], starttls: bool) -> None: ...
def list_folders(self) -> Tuple[str, List[bytes]]: ...
def connect(self, username: str, password: str) -> Union[IMAP4, IMAP4_SSL]: ...

View File

@@ -1,23 +1,44 @@
import imaplib
from imbox.imap import ImapTransport
from imbox.messages import Messages
import logging
from imbox.vendors import GmailMessages, hostname_vendorname_dict, name_authentication_string_dict
logger = logging.getLogger(__name__)
class Imbox:
authentication_error_message = None
def __init__(self, hostname, username=None, password=None, ssl=True,
port=None, ssl_context=None, policy=None, starttls=False):
port=None, ssl_context=None, policy=None, starttls=False,
vendor=None):
self.server = ImapTransport(hostname, ssl=ssl, port=port,
ssl_context=ssl_context, starttls=starttls)
self.hostname = hostname
self.username = username
self.password = password
self.parser_policy = policy
self.connection = self.server.connect(username, password)
self.vendor = vendor or hostname_vendorname_dict.get(self.hostname)
if self.vendor is not None:
self.authentication_error_message = name_authentication_string_dict.get(
self.vendor)
try:
self.connection = self.server.connect(username, password)
except imaplib.IMAP4.error as e:
if self.authentication_error_message is None:
raise
raise imaplib.IMAP4.error(
self.authentication_error_message + '\n' + str(e))
logger.info("Connected to IMAP Server with user {username} on {hostname}{ssl}".format(
hostname=hostname, username=username, ssl=(" over SSL" if ssl or starttls else "")))
@@ -42,31 +63,42 @@ class Imbox:
self.connection.uid('STORE', uid, '+FLAGS', '(\\Flagged)')
def delete(self, uid):
logger.info("Mark UID {} with \\Deleted FLAG and expunge.".format(int(uid)))
logger.info(
"Mark UID {} with \\Deleted FLAG and expunge.".format(int(uid)))
self.connection.expunge()
def copy(self, uid, destination_folder):
logger.info("Copy UID {} to {} folder".format(int(uid), str(destination_folder)))
logger.info("Copy UID {} to {} folder".format(
int(uid), str(destination_folder)))
return self.connection.uid('COPY', uid, destination_folder)
def move(self, uid, destination_folder):
logger.info("Move UID {} to {} folder".format(int(uid), str(destination_folder)))
logger.info("Move UID {} to {} folder".format(
int(uid), str(destination_folder)))
if self.copy(uid, destination_folder):
self.delete(uid)
def messages(self, **kwargs):
folder = kwargs.get('folder', False)
messages_class = Messages
if self.vendor == 'gmail':
messages_class = GmailMessages
if folder:
self.connection.select(folder)
self.connection.select(
messages_class.FOLDER_LOOKUP.get((folder.lower())) or folder)
msg = " from folder '{}'".format(folder)
del kwargs['folder']
else:
msg = " from inbox"
logger.info("Fetch list of messages{}".format(msg))
return Messages(connection=self.connection,
parser_policy=self.parser_policy,
**kwargs)
return messages_class(connection=self.connection,
parser_policy=self.parser_policy,
**kwargs)
def folders(self):
return self.connection.list()
return self.connection.list()

31
imbox/imbox.pyi Normal file
View File

@@ -0,0 +1,31 @@
import datetime
from email._policybase import Policy
from inspect import Traceback
from ssl import SSLContext
from typing import Optional, Union, Tuple, List
class Imbox:
def __init__(self, hostname: str, username: Optional[str], password: Optional[str], ssl: bool,
port: Optional[int], ssl_context: Optional[SSLContext], policy: Optional[Policy], starttls: bool): ...
def __enter__(self) -> 'Imbox': ...
def __exit__(self, type: Exception, value: str, traceback: Traceback) -> None: ...
def logout(self) -> None: ...
def mark_seen(self, uid: bytes) -> None: ...
def mark_flag(self, uid: bytes) -> None: ...
def delete(self, uid: bytes) -> None: ...
def copy(self, uid: bytes, destination_folder: Union[bytes, str]) -> Tuple[str, Union[list, List[None, bytes]]]: ...
def move(self, uid: bytes, destination_folder: Union[bytes, str]) -> None: ...
def messages(self, **kwargs: Union[bool, str, datetime.date]) -> 'Messages': ...
def folders(self) -> Tuple[str, List[bytes]]: ...

View File

@@ -1,13 +1,30 @@
from imbox.parser import fetch_email_by_uid
from imbox.query import build_search_query
import datetime
import logging
from imbox.query import build_search_query
from imbox.parser import fetch_email_by_uid
logger = logging.getLogger(__name__)
class Messages:
IMAP_ATTRIBUTE_LOOKUP = {
'unread': '(UNSEEN)',
'flagged': '(FLAGGED)',
'unflagged': '(UNFLAGGED)',
'sent_from': '(FROM "{}")',
'sent_to': '(TO "{}")',
'date__gt': '(SINCE "{}")',
'date__lt': '(BEFORE "{}")',
'date__on': '(ON "{}")',
'subject': '(SUBJECT "{}")',
'uid__range': '(UID {})',
}
FOLDER_LOOKUP = {}
def __init__(self,
connection,
parser_policy,
@@ -26,8 +43,8 @@ class Messages:
parser_policy=self.parser_policy)
def _query_uids(self, **kwargs):
query_ = build_search_query(**kwargs)
message, data = self.connection.uid('search', None, query_)
query_ = build_search_query(self.IMAP_ATTRIBUTE_LOOKUP, **kwargs)
_, data = self.connection.uid('search', None, query_)
if data[0] is None:
return []
return data[0].split()

28
imbox/messages.pyi Normal file
View File

@@ -0,0 +1,28 @@
import datetime
from email._policybase import Policy
from imaplib import IMAP4, IMAP4_SSL
from typing import Union, List, Generator, Tuple
class Messages:
def __init__(self,
connection: Union[IMAP4, IMAP4_SSL],
parser_policy: Policy,
**kwargs: Union[bool, str, datetime.date]) -> None: ...
def _fetch_email(self, uid: bytes) -> 'Struct': ...
def _query_uids(self, **kwargs: Union[bool, str, datetime.date]) -> List[bytes]: ...
def _fetch_email_list(self) -> Generator[Tuple[bytes, 'Struct']]: ...
def __repr__(self) -> str: ...
def __iter__(self) -> Generator[Tuple[bytes, 'Struct']]: ...
def __next__(self) -> 'Messages': ...
def __len__(self) -> int: ...
def __getitem__(self, index) -> Union['Struct', List['Struct']]: ...

View File

@@ -60,7 +60,8 @@ def get_mail_addresses(message, header_name):
for index, (address_name, address_email) in enumerate(addresses):
addresses[index] = {'name': decode_mail_header(address_name),
'email': address_email}
logger.debug("{} Mail address in message: <{}> {}".format(header_name.upper(), address_name, address_email))
logger.debug("{} Mail address in message: <{}> {}".format(
header_name.upper(), address_name, address_email))
return addresses
@@ -111,7 +112,8 @@ def parse_attachment(message_part):
name, value = decode_param(param)
if 'file' in name:
attachment['filename'] = value[1:-1] if value.startswith('"') else value
attachment['filename'] = value[1:-
1] if value.startswith('"') else value
if 'create-date' in name:
attachment['create-date'] = value
@@ -126,6 +128,8 @@ def decode_content(message):
charset = message.get_content_charset('utf-8')
try:
return content.decode(charset, 'ignore')
except LookupError:
return content.decode(charset.replace("-", ""), 'ignore')
except AttributeError:
return content
@@ -161,9 +165,11 @@ def parse_email(raw_email, policy=None):
email_parse_kwargs = {}
try:
email_message = email.message_from_string(raw_email, **email_parse_kwargs)
email_message = email.message_from_string(
raw_email, **email_parse_kwargs)
except UnicodeEncodeError:
email_message = email.message_from_string(raw_email.encode('utf-8'), **email_parse_kwargs)
email_message = email.message_from_string(
raw_email.encode('utf-8'), **email_parse_kwargs)
maintype = email_message.get_content_maintype()
parsed_email = {'raw_email': raw_email}
@@ -185,7 +191,7 @@ def parse_email(raw_email, policy=None):
content = decode_content(part)
is_inline = content_disposition is None \
or content_disposition.startswith("inline")
or content_disposition.startswith("inline")
if content_type == "text/plain" and is_inline:
body['plain'].append(content)
elif content_type == "text/html" and is_inline:

34
imbox/parser.pyi Normal file
View File

@@ -0,0 +1,34 @@
import datetime
from email._policybase import Policy
from email.message import Message
from imaplib import IMAP4_SSL
import io
from typing import Union, Dict, List, KeysView, Tuple, Optional
class Struct:
def __init__(self, **entries: Union[
str, datetime.datetime, Dict[str, str], list, List[Dict[str, str]]
]) -> None: ...
def keys(self) -> KeysView: ...
def __repr__(self) -> str: ...
def decode_mail_header(value: str, default_charset: str) -> str: ...
def get_mail_addresses(message: Message, header_name: str) -> List[Dict[str, str]]: ...
def decode_param(param: str) -> Tuple[str, str]: ...
def parse_attachment(message_part: Message) -> Optional[Dict[str, Union[int, str, io.BytesIO]]]: ...
def decode_content(message: Message) -> str: ...
def fetch_email_by_uid(uid: bytes, connection: IMAP4_SSL, parser_policy: Optional[Policy]) -> Struct: ...
raw_headers: bytes
raw_email: bytes
def parse_flags(headers: str) -> Union[list, List[bytes]]: ...
def parse_email(raw_email: bytes, policy: Optional[Policy]) -> Struct: ...

View File

@@ -1,61 +1,17 @@
import datetime
import logging
# TODO - Validate query arguments
logger = logging.getLogger(__name__)
def format_date(date):
if isinstance(date, datetime.date):
return date.strftime('%d-%b-%Y')
return date
def build_search_query(**kwargs):
# Parse keyword arguments
unread = kwargs.get('unread', False)
unflagged = kwargs.get('unflagged', False)
flagged = kwargs.get('flagged', False)
sent_from = kwargs.get('sent_from', False)
sent_to = kwargs.get('sent_to', False)
date__gt = kwargs.get('date__gt', False)
date__lt = kwargs.get('date__lt', False)
date__on = kwargs.get('date__on', False)
subject = kwargs.get('subject')
def build_search_query(imap_attribute_lookup, **kwargs):
query = []
if unread:
query.append("(UNSEEN)")
if unflagged:
query.append("(UNFLAGGED)")
if flagged:
query.append("(FLAGGED)")
if sent_from:
query.append('(FROM "%s")' % sent_from)
if sent_to:
query.append('(TO "%s")' % sent_to)
if date__gt:
query.append('(SINCE "%s")' % format_date(date__gt))
if date__lt:
query.append('(BEFORE "%s")' % format_date(date__lt))
if date__on:
query.append('(ON "%s")' % format_date(date__on))
if subject is not None:
query.append('(SUBJECT "%s")' % subject)
for name, value in kwargs.items():
if value is not None:
if isinstance(value, datetime.date):
value = value.strftime('%d-%b-%Y')
if isinstance(value, str) and '"' in value:
value = value.replace('"', "'")
query.append(imap_attribute_lookup[name].format(value))
if query:
logger.debug("IMAP query: {}".format(" ".join(query)))
return " ".join(query)
logger.debug("IMAP query: {}".format("(ALL)"))
return "(ALL)"

6
imbox/utils.pyi Normal file
View File

@@ -0,0 +1,6 @@
from typing import Optional, Union
def str_encode(value: Union[str, bytes], encoding: Optional[str], errors: str) -> str: ...
def str_decode(value: Union[str, bytes], encoding: Optional[str], errors: str) -> Union[str, bytes]: ...

11
imbox/vendors/__init__.py vendored Normal file
View File

@@ -0,0 +1,11 @@
from imbox.vendors.gmail import GmailMessages
vendors = [GmailMessages]
hostname_vendorname_dict = {vendor.hostname: vendor.name for vendor in vendors}
name_authentication_string_dict = {vendor.name: vendor.authentication_error_message for vendor in vendors}
__all__ = [v.__name__ for v in vendors]
__all__ += ['hostname_vendorname_dict',
'name_authentication_string_dict']

9
imbox/vendors/__init__.pyi vendored Normal file
View File

@@ -0,0 +1,9 @@
from typing import List, Dict, Optional
from imbox.messages import Messages
vendors: List[Messages]
hostname_vendorname_dict: Dict[str, str]
name_authentication_string_dict: Dict[str, Optional[str]]

39
imbox/vendors/gmail.py vendored Normal file
View File

@@ -0,0 +1,39 @@
from imbox.messages import Messages
from imbox.vendors.helpers import merge_two_dicts
class GmailMessages(Messages):
authentication_error_message = ('If you\'re not using an app-specific password, grab one here: '
'https://myaccount.google.com/apppasswords')
hostname = 'imap.gmail.com'
name = 'gmail'
FOLDER_LOOKUP = {
'all_mail': '"[Gmail]/All Mail"',
'all': '"[Gmail]/All Mail"',
'all mail': '"[Gmail]/All Mail"',
'sent': '"[Gmail]/Sent Mail"',
'sent mail': '"[Gmail]/Sent Mail"',
'sent_mail': '"[Gmail]/Sent Mail"',
'drafts': '"[Gmail]/Drafts"',
'important': '"[Gmail]/Important"',
'spam': '"[Gmail]/Spam"',
'starred': '"[Gmail]/Starred"',
'trash': '"[Gmail]/Trash"',
}
GMAIL_IMAP_ATTRIBUTE_LOOKUP_DIFF = {
'subject': '(X-GM-RAW "subject:\'{}\'")',
'label': '(X-GM-LABELS "{}")',
'raw': '(X-GM-RAW "{}")'
}
def __init__(self,
connection,
parser_policy,
**kwargs):
self.IMAP_ATTRIBUTE_LOOKUP = merge_two_dicts(self.IMAP_ATTRIBUTE_LOOKUP,
self.GMAIL_IMAP_ATTRIBUTE_LOOKUP_DIFF)
super().__init__(connection, parser_policy, **kwargs)

14
imbox/vendors/gmail.pyi vendored Normal file
View File

@@ -0,0 +1,14 @@
import datetime
from email._policybase import Policy
from imaplib import IMAP4, IMAP4_SSL
from typing import Union
from imbox.messages import Messages
class GmailMessages(Messages):
def __init__(self,
connection: Union[IMAP4, IMAP4_SSL],
parser_policy: Policy,
**kwargs: Union[bool, str, datetime.date]) -> None: ...

6
imbox/vendors/helpers.py vendored Normal file
View File

@@ -0,0 +1,6 @@
def merge_two_dicts(x, y):
"""from https://stackoverflow.com/a/26853961/4386191"""
z = x.copy() # start with x's keys and values
z.update(y) # modifies z with y's keys and values & returns None
return z

View File

@@ -19,7 +19,7 @@ setup(
author_email='martin@amon.cx',
url='https://github.com/martinrusev/imbox',
license='MIT',
packages=['imbox'],
packages=['imbox', 'imbox.vendors'],
package_dir={'imbox': 'imbox'},
zip_safe=False,
classifiers=(
@@ -27,7 +27,8 @@ setup(
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6'
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
),
test_suite='tests',
)

View File

@@ -274,6 +274,44 @@ R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
--____NOIBTUQXSYRVOOAFLCHY____--
"""
raw_email_encoded_encoding_charset_contains_a_minus = b"""Delivered-To: <receiver@example.org>
Return-Path: <sender@example.org>
Message-ID: <74836CF6FF9B1965927DE7EE8A087483@NXOFGRQFQW2>
From: <sender@example.org>
To: <sender@example.org>
Subject: Salut, mon cher.
Date: 30 May 2018 22:47:37 +0200
MIME-Version: 1.0
Content-Type: multipart/alternative;
boundary="----=_NextPart_000_0038_01D3F85C.02934C4A"
------=_NextPart_000_0038_01D3F85C.02934C4A
Content-Type: text/plain;
charset="cp-850"
Content-Transfer-Encoding: quoted-printable
spam here
cliquez ici
------=_NextPart_000_0038_01D3F85C.02934C4A
Content-Type: text/html;
charset="cp-850"
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<HTML><HEAD>
<META http-equiv=3DContent-Type content=3D"text/html; charset=3Dcp-850">
<META content=3D"MSHTML 6.00.2900.2456" name=3DGENERATOR>
<STYLE></STYLE>
</HEAD>
<BODY bgColor=3D#ffffff>
spam here<br>
<br>
<a href=3D"http://spammer-url"><b>cliquez =
ici</b></a></br></BODY></HTML>
------=_NextPart_000_0038_01D3F85C.02934C4A--
"""
class TestParser(unittest.TestCase):
@@ -324,6 +362,12 @@ class TestParser(unittest.TestCase):
self.assertEqual('abc.xyz', attachment['filename'])
self.assertTrue(attachment['content'])
def test_parse_email_accept_if_declared_charset_contains_a_minus_character(self):
parsed_email = parse_email(raw_email_encoded_encoding_charset_contains_a_minus)
self.assertEqual("Salut, mon cher.", parsed_email.subject)
self.assertTrue(parsed_email.body['plain'])
self.assertTrue(parsed_email.body['html'])
# TODO - Complete the test suite
def test_decode_mail_header(self):
pass

View File

@@ -1,50 +1,83 @@
import unittest
from imbox.query import build_search_query
from datetime import date
import unittest
from imbox.query import build_search_query
from imbox.messages import Messages
from imbox.vendors.helpers import merge_two_dicts
from imbox.vendors.gmail import GmailMessages
IMAP_ATTRIBUTE_LOOKUP = Messages.IMAP_ATTRIBUTE_LOOKUP
GMAIL_ATTRIBUTE_LOOKUP = merge_two_dicts(IMAP_ATTRIBUTE_LOOKUP,
GmailMessages.GMAIL_IMAP_ATTRIBUTE_LOOKUP_DIFF)
class TestQuery(unittest.TestCase):
def test_all(self):
res = build_search_query()
res = build_search_query(IMAP_ATTRIBUTE_LOOKUP)
self.assertEqual(res, "(ALL)")
def test_subject(self):
res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, subject='hi')
self.assertEqual(res, '(SUBJECT "hi")')
res = build_search_query(GMAIL_ATTRIBUTE_LOOKUP, subject='hi')
self.assertEqual(res, '(X-GM-RAW "subject:\'hi\'")')
def test_unread(self):
res = build_search_query(unread=True)
res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, unread=True)
self.assertEqual(res, "(UNSEEN)")
def test_unflagged(self):
res = build_search_query(unflagged=True)
res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, unflagged=True)
self.assertEqual(res, "(UNFLAGGED)")
def test_flagged(self):
res = build_search_query(flagged=True)
res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, flagged=True)
self.assertEqual(res, "(FLAGGED)")
def test_sent_from(self):
res = build_search_query(sent_from='test@example.com')
res = build_search_query(
IMAP_ATTRIBUTE_LOOKUP, sent_from='test@example.com')
self.assertEqual(res, '(FROM "test@example.com")')
def test_sent_to(self):
res = build_search_query(sent_to='test@example.com')
res = build_search_query(
IMAP_ATTRIBUTE_LOOKUP, sent_to='test@example.com')
self.assertEqual(res, '(TO "test@example.com")')
def test_date__gt(self):
res = build_search_query(date__gt=date(2014, 12, 31))
res = build_search_query(
IMAP_ATTRIBUTE_LOOKUP, date__gt=date(2014, 12, 31))
self.assertEqual(res, '(SINCE "31-Dec-2014")')
def test_date__lt(self):
res = build_search_query(date__lt=date(2014, 1, 1))
res = build_search_query(
IMAP_ATTRIBUTE_LOOKUP, date__lt=date(2014, 1, 1))
self.assertEqual(res, '(BEFORE "01-Jan-2014")')
def test_date__on(self):
res = build_search_query(date__on=date(2014, 1, 1))
res = build_search_query(
IMAP_ATTRIBUTE_LOOKUP, date__on=date(2014, 1, 1))
self.assertEqual(res, '(ON "01-Jan-2014")')
def test_uid__range(self):
res = build_search_query(IMAP_ATTRIBUTE_LOOKUP, uid__range='1000:*')
self.assertEqual(res, '(UID 1000:*)')
def test_gmail_raw(self):
res = build_search_query(GMAIL_ATTRIBUTE_LOOKUP, raw='has:attachment subject:"hey"')
self.assertEqual(res, '(X-GM-RAW "has:attachment subject:\'hey\'")')
def test_gmail_label(self):
res = build_search_query(GMAIL_ATTRIBUTE_LOOKUP, label='finance')
self.assertEqual(res, '(X-GM-LABELS "finance")')