Skip to content

Commit

Permalink
* Added 14 new custom lib exceptions (errors.py): MailboxCopyError, M…
Browse files Browse the repository at this point in the history
…ailboxDeleteError, MailboxExpungeError, MailboxFetchError, MailboxFlagError, MailboxFolderCreateError, MailboxFolderDeleteError, MailboxFolderRenameError, MailboxFolderSelectError, MailboxFolderStatusError, MailboxFolderStatusValueError, MailboxLoginError, MailboxLogoutError, MailboxSearchError

* UnexpectedCommandStatusError now not used directly.
* Added folder.MailBoxFolderStatusOptions class instead MailBoxFolderManager.folder_status_options
* utils.MessageFlags -> message.MailMessageFlags
* query.py: ValueError replaced to TypeError in many places
* utils.short_month_names renamed to utils.SHORT_MONTH_NAMES
* utils.cleaned_uid_set - parsing optimized, raise TypeError instead ValueError
* utils.check_command_status - new logic
* BaseMailBox.fetch headers_only arg is disabled until fix
  • Loading branch information
ikvk committed Jul 10, 2020
1 parent 38a96ce commit 87bfb80
Show file tree
Hide file tree
Showing 15 changed files with 242 additions and 106 deletions.
10 changes: 8 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Basic
subjects = [msg.subject for msg in mailbox.fetch(AND(all=True))]
mailbox.logout()
MailBox/MailBoxUnencrypted for create mailbox instance.
MailBox/MailBoxUnencrypted - for create mailbox instance.

MailBox.box - imaplib.IMAP4/IMAP4_SSL client instance.

Expand Down Expand Up @@ -207,7 +207,7 @@ use 'limit' argument for fetch in this case.
mailbox.delete([msg.uid for msg in mailbox.fetch()])
# FLAG unseen messages in current folder as Answered and Flagged, *in bulk.
flags = (imap_tools.MessageFlags.ANSWERED, imap_tools.MessageFlags.FLAGGED)
flags = (imap_tools.MailMessageFlags.ANSWERED, imap_tools.MailMessageFlags.FLAGGED)
mailbox.flag(mailbox.fetch('(UNSEEN)'), flags, True)
# SEEN: mark all messages sent at 05.03.2007 in current folder as unseen, *in bulk
Expand Down Expand Up @@ -237,6 +237,11 @@ Actions with mailbox folders
folder_status = mailbox.folder.status('some_folder')
print(folder_status) # {'MESSAGES': 41, 'RECENT': 0, 'UIDNEXT': 11996, 'UIDVALIDITY': 1, 'UNSEEN': 5}
Exceptions
^^^^^^^^^^

Custom lib exceptions here: `errors.py <https://github.com/ikvk/imap_tools/blob/master/imap_tools/errors.py>`_.

Reasons
-------

Expand Down Expand Up @@ -271,3 +276,4 @@ Thanks to:
* `daitangio <https://github.com/daitangio>`_
* `upils <https://github.com/upils>`_
* `Foosec <https://github.com/Foosec>`_
* `frispete <https://github.com/frispete>`_
3 changes: 2 additions & 1 deletion imap_tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
from .message import *
from .folder import *
from .utils import *
from .errors import *

__version__ = '0.17.0'
__version__ = '0.18.0'
74 changes: 74 additions & 0 deletions imap_tools/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
class ImapToolsError(Exception):
"""Base lib error"""


class MailboxFolderStatusValueError(ImapToolsError):
"""Wrong folder status value error"""


class UnexpectedCommandStatusError(ImapToolsError):
"""Unexpected status in IMAP command response"""

def __init__(self, command_result: tuple, expected: str):
"""
:param command_result: imap command result
:param expected: expected command status
"""
self.command_result = command_result
self.expected = expected

def __str__(self):
return 'Response status "{exp}" expected, but "{typ}" received. Data: {data}'.format(
exp=self.expected, typ=self.command_result[0], data=str(self.command_result[1]))


class MailboxFolderSelectError(UnexpectedCommandStatusError):
pass


class MailboxFolderCreateError(UnexpectedCommandStatusError):
pass


class MailboxFolderRenameError(UnexpectedCommandStatusError):
pass


class MailboxFolderDeleteError(UnexpectedCommandStatusError):
pass


class MailboxFolderStatusError(UnexpectedCommandStatusError):
pass


class MailboxLoginError(UnexpectedCommandStatusError):
pass


class MailboxLogoutError(UnexpectedCommandStatusError):
pass


class MailboxSearchError(UnexpectedCommandStatusError):
pass


class MailboxFetchError(UnexpectedCommandStatusError):
pass


class MailboxExpungeError(UnexpectedCommandStatusError):
pass


class MailboxDeleteError(UnexpectedCommandStatusError):
pass


class MailboxCopyError(UnexpectedCommandStatusError):
pass


class MailboxFlagError(UnexpectedCommandStatusError):
pass
53 changes: 32 additions & 21 deletions imap_tools/folder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,32 @@

from . import imap_utf7
from .utils import check_command_status, quote, pairs_to_dict


class MailBoxFolderWrongStatusError(Exception):
"""Wrong folder status error"""
from .errors import MailboxFolderStatusValueError, MailboxFolderSelectError, MailboxFolderCreateError, \
MailboxFolderRenameError, MailboxFolderDeleteError, MailboxFolderStatusError


class MailBoxFolderStatusOptions:
"""Valid mailbox folder status options"""
MESSAGES = 'MESSAGES'
RECENT = 'RECENT'
UIDNEXT = 'UIDNEXT'
UIDVALIDITY = 'UIDVALIDITY'
UNSEEN = 'UNSEEN'
all = (
MESSAGES, RECENT, UIDNEXT, UIDVALIDITY, UNSEEN
)
description = (
(MESSAGES, "The number of messages in the mailbox"),
(RECENT, "The number of messages with the Recent flag set"),
(UIDNEXT, "The next unique identifier value of the mailbox"),
(UIDVALIDITY, "The unique identifier validity value of the mailbox"),
(UNSEEN, "The number of messages which do not have the Seen flag set"),
)


class MailBoxFolderManager:
"""Operations with mail box folders"""

folder_status_options = ('MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN')

def __init__(self, mailbox):
self.mailbox = mailbox
self._current_folder = None
Expand All @@ -28,7 +43,7 @@ def _encode_folder(folder: str or bytes) -> bytes:
def set(self, folder: str or bytes):
"""Select current folder"""
result = self.mailbox.box.select(self._encode_folder(folder))
check_command_status('box.select', result)
check_command_status(result, MailboxFolderSelectError)
self._current_folder = folder
return result

Expand All @@ -42,7 +57,7 @@ def create(self, folder: str or bytes):
*Use email box delimiter to separate folders. Example for "|" delimiter: "folder|sub folder"
"""
result = self.mailbox.box._simple_command('CREATE', self._encode_folder(folder))
check_command_status('CREATE', result)
check_command_status(result, MailboxFolderCreateError)
return result

def get(self):
Expand All @@ -53,37 +68,33 @@ def rename(self, old_name: str or bytes, new_name: str or bytes):
"""Renemae folder from old_name to new_name"""
result = self.mailbox.box._simple_command(
'RENAME', self._encode_folder(old_name), self._encode_folder(new_name))
check_command_status('RENAME', result)
check_command_status(result, MailboxFolderRenameError)
return result

def delete(self, folder: str or bytes):
"""Delete folder"""
result = self.mailbox.box._simple_command('DELETE', self._encode_folder(folder))
check_command_status('DELETE', result)
check_command_status(result, MailboxFolderDeleteError)
return result

def status(self, folder: str or bytes, options: [str] or None = None) -> dict:
"""
Get the status of a folder
:param folder: mailbox folder
:param options: [str] with values from MailBoxFolderManager.folder_status_options | None - for get all options
MESSAGES - The number of messages in the mailbox.
RECENT - The number of messages with the Recent flag set.
UIDNEXT - The next unique identifier value of the mailbox.
UIDVALIDITY - The unique identifier validity value of the mailbox.
UNSEEN - The number of messages which do not have the Seen flag set.
:param options: [str] with values from MailBoxFolderStatusOptions.all | None - for get all options
:return: dict with available options keys
"""
command = 'STATUS'
if not options:
options = self.folder_status_options
if not all((i in self.folder_status_options for i in options)):
raise MailBoxFolderWrongStatusError(str(options))
options = tuple(MailBoxFolderStatusOptions.all)
for opt in options:
if opt not in MailBoxFolderStatusOptions.all:
raise MailboxFolderStatusValueError(str(opt))
status_result = self.mailbox.box._simple_command(
command, self._encode_folder(folder), '({})'.format(' '.join(options)))
check_command_status(command, status_result)
check_command_status(status_result, MailboxFolderStatusError)
result = self.mailbox.box._untagged_response(status_result[0], status_result[1], command)
check_command_status(command, result)
check_command_status(result, MailboxFolderStatusError)
values = result[1][0].decode().split('(')[1].split(')')[0].split(' ')
return {k: int(v) for k, v in pairs_to_dict(values).items() if str(v).isdigit()}

Expand Down
28 changes: 16 additions & 12 deletions imap_tools/mailbox.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import imaplib

from .message import MailMessage
from .message import MailMessage, MailMessageFlags
from .folder import MailBoxFolderManager
from .utils import cleaned_uid_set, check_command_status, MessageFlags
from .utils import cleaned_uid_set, check_command_status
from .errors import MailboxLoginError, MailboxLogoutError, MailboxSearchError, MailboxFetchError, MailboxExpungeError, \
MailboxDeleteError, MailboxCopyError, MailboxFlagError

# Maximal line length when calling readline(). This is to prevent reading arbitrary length lines.
imaplib._MAXLINE = 4 * 1024 * 1024 # 4Mb
Expand All @@ -24,15 +26,15 @@ def _get_mailbox_client(self) -> imaplib.IMAP4:

def login(self, username: str, password: str, initial_folder: str = 'INBOX'):
result = self.box.login(username, password)
check_command_status('box.login', result)
check_command_status(result, MailboxLoginError)
self.folder = self.folder_manager_class(self)
self.folder.set(initial_folder)
self.login_result = result
return self # return self in favor of context manager

def logout(self):
result = self.box.logout()
check_command_status('box.logout', result, expected='BYE')
check_command_status(result, MailboxLogoutError, expected='BYE')
return result

@staticmethod
Expand All @@ -54,8 +56,10 @@ def fetch(self, criteria: str or bytes = 'ALL', charset: str = 'US-ASCII', limit
:param headers_only: get only email headers (without text, html, attachments)
:return generator: MailMessage
"""
if headers_only:
raise NotImplementedError('headers_only does not work correctly and is disabled until fix, *you may help')
search_result = self.box.search(charset, self._criteria_encoder(criteria, charset))
check_command_status('box.search', search_result)
check_command_status(search_result, MailboxSearchError)
# first element is string with email numbers through the gap
message_id_set = search_result[1][0].decode().split(' ') if search_result[1][0] else ()
message_parts = "(BODY{}[{}] UID FLAGS)".format('' if mark_seen else '.PEEK', 'HEADER' if headers_only else '')
Expand All @@ -64,7 +68,7 @@ def fetch(self, criteria: str or bytes = 'ALL', charset: str = 'US-ASCII', limit
break
# get message by id
fetch_result = self.box.fetch(message_id, message_parts)
check_command_status('box.fetch', fetch_result)
check_command_status(fetch_result, MailboxFetchError)
mail_message = self.email_message_class(fetch_result[1])
if miss_defect and mail_message.obj.defects:
continue
Expand All @@ -74,7 +78,7 @@ def fetch(self, criteria: str or bytes = 'ALL', charset: str = 'US-ASCII', limit

def expunge(self) -> tuple:
result = self.box.expunge()
check_command_status('box.expunge', result)
check_command_status(result, MailboxExpungeError)
return result

def delete(self, uid_list) -> (tuple, tuple) or None:
Expand All @@ -87,7 +91,7 @@ def delete(self, uid_list) -> (tuple, tuple) or None:
if not uid_str:
return None
store_result = self.box.uid('STORE', uid_str, '+FLAGS', r'(\Deleted)')
check_command_status('box.delete', store_result)
check_command_status(store_result, MailboxDeleteError)
expunge_result = self.expunge()
return store_result, expunge_result

Expand All @@ -101,7 +105,7 @@ def copy(self, uid_list, destination_folder: str) -> tuple or None:
if not uid_str:
return None
copy_result = self.box.uid('COPY', uid_str, destination_folder)
check_command_status('box.copy', copy_result)
check_command_status(copy_result, MailboxCopyError)
return copy_result

def move(self, uid_list, destination_folder: str) -> (tuple, tuple) or None:
Expand All @@ -122,7 +126,7 @@ def flag(self, uid_list, flag_set: [str] or str, value: bool) -> (tuple, tuple)
"""
Set/unset email flags
Do nothing on empty uid_list
Standard flags contains in MessageFlags.all
Standard flags contains in message.MailMessageFlags.all
:return: None on empty uid_list, command results otherwise
"""
uid_str = cleaned_uid_set(uid_list)
Expand All @@ -133,7 +137,7 @@ def flag(self, uid_list, flag_set: [str] or str, value: bool) -> (tuple, tuple)
store_result = self.box.uid(
'STORE', uid_str, ('+' if value else '-') + 'FLAGS',
'({})'.format(' '.join(('\\' + i for i in flag_set))))
check_command_status('box.flag', store_result)
check_command_status(store_result, MailboxFlagError)
expunge_result = self.expunge()
return store_result, expunge_result

Expand All @@ -142,7 +146,7 @@ def seen(self, uid_list, seen_val: bool) -> (tuple, tuple) or None:
Mark email as read/unread
This is shortcut for flag method
"""
return self.flag(uid_list, MessageFlags.SEEN, seen_val)
return self.flag(uid_list, MailMessageFlags.SEEN, seen_val)

def __enter__(self):
return self
Expand Down
13 changes: 13 additions & 0 deletions imap_tools/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@
from .utils import decode_value, parse_email_addresses, parse_email_date


class MailMessageFlags:
"""Standard email message flags"""
SEEN = 'SEEN'
ANSWERED = 'ANSWERED'
FLAGGED = 'FLAGGED'
DELETED = 'DELETED'
DRAFT = 'DRAFT'
RECENT = 'RECENT'
all = (
SEEN, ANSWERED, FLAGGED, DELETED, DRAFT, RECENT
)


class MailMessage:
"""The email message"""

Expand Down
Loading

0 comments on commit 87bfb80

Please sign in to comment.