Skip to content

Commit

Permalink
+mailbox.idle, +BaseMailBox.consume_until_tagged_response, +MailboxTa…
Browse files Browse the repository at this point in the history
…ggedResponseError, -BaseMailBox.with_headers_only_allowed_errors
  • Loading branch information
ikvk committed Feb 18, 2022
1 parent 63ffa1b commit f7ba30e
Show file tree
Hide file tree
Showing 11 changed files with 260 additions and 10 deletions.
31 changes: 31 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Work with email by IMAP:
- Query builder for searching emails
- Actions with emails: copy, delete, flag, move, append
- Actions with folders: list, set, get, create, exists, rename, subscribe, delete, status
- Supports IDLE command
- Exceptions on failed operations
- No external dependencies

Expand Down Expand Up @@ -291,6 +292,36 @@ BaseMailBox.login has initial_folder arg, that is "INBOX" by default, use None f
stat = mailbox.folder.status('some_folder')
print(stat) # {'MESSAGES': 41, 'RECENT': 0, 'UIDNEXT': 11996, 'UIDVALIDITY': 1, 'UNSEEN': 5}
IDLE workflow
^^^^^^^^^^^^^

IDLE logic a in mailbox.idle manager, its methods are in the table below:

======== =================================================================== ==========================
Method Description Arguments
======== =================================================================== ==========================
start Switch on mailbox IDLE mode
poll Poll for IDLE responses timeout: Optional[float]
stop Switch off mailbox IDLE mode
wait Switch on IDLE, poll responses, switch off IDLE, return responses timeout: Optional[float]
======== =================================================================== ==========================

.. code-block:: python
import time
from imap_tools import MailBox, A
# waiting for updates 60 sec, print unseen immediately if any update
with MailBox('imap.my.moon').login('acc', 'pwd', 'INBOX') as mailbox:
responses = mailbox.idle.wait(timeout=60)
if responses:
for msg in mailbox.fetch(A(seen=False)):
print(msg.date, msg.subject)
else:
print('no updates in 60 sec')
Read docstrings and see `detailed examples <https://github.com/ikvk/imap_tools/blob/master/examples/idle.py>`_.

Exceptions
^^^^^^^^^^

Expand Down
6 changes: 3 additions & 3 deletions docs/donate.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ My PayPal 💰
| https://paypal.me/KaukinVK
| [email protected]
Star imap_tools project ⭐
https://github.com/ikvk/imap_tools

Thanks to all who donated 🎉
It is really nice.

Targeted fundraising 🎯
| 3k$ for create documentation. Style: https://alabaster.readthedocs.io/en/latest/
| Considering the dynamics - in ~100 years :D
| So you'd better buy strings for my balalaika and meat for my bear.
Do not forget to star imap_tools project ⭐
https://github.com/ikvk/imap_tools
2 changes: 1 addition & 1 deletion docs/full_docs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ I tried to describe the library API as much as possible in README.rst
If you are using some imap_tools entity, just look at its args at source code by your IDE:
doc strings are present, argument types annotated.

Maybe I will make docs after 1.0.0 release or after donation for it.
I will make docs after donation for it, see donate.rst
7 changes: 7 additions & 0 deletions docs/release_notes.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
0.51.0
======
* Added idle manager for work with IDLE: mailbox.idle.<[start,poll,stop,wait]>
* Added BaseMailBox.consume_until_tagged_response method: waiting for tagged response
* Added new exception: MailboxTaggedResponseError
* Removed unused stuff: BaseMailBox.with_headers_only_allowed_errors

0.50.2
======
* query.ParamConverter._gen_values minor improvement
Expand Down
45 changes: 45 additions & 0 deletions examples/idle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import time
from imap_tools import MailBox, A

# SIMPLE
# waiting for msg in 60 sec, then print unseen if any update
with MailBox('imap.far.mars').login('acc', 'pwd') as mailbox:
# in idle mode
mailbox.idle.start()
responses = mailbox.idle.poll(timeout=60)
mailbox.idle.stop()
# in not idle mode
if responses:
for msg in mailbox.fetch(A(seen=False)):
print(msg.date, msg.subject)
else:
print('no any updates')

# reliable console notificator
import time, socket, imaplib, traceback
from imap_tools import A, MailBox, MailboxLoginError, MailboxLogoutError

done = False
while not done:
connection_start_time = time.monotonic()
connection_live_time = 0.0
try:
with MailBox('imap.my.moon').login('acc', 'pwd', 'INBOX') as mailbox:
print('@@ new connection', time.asctime())
while connection_live_time < 29 * 60:
try:
responses = mailbox.idle.wait(timeout=3 * 60)
print(time.asctime(), 'IDLE responses:', responses)
if responses:
for msg in mailbox.fetch(A(seen=False)):
print('->', msg.date, msg.subject)
except KeyboardInterrupt:
print('~KeyboardInterrupt')
done = True
break
connection_live_time = time.monotonic() - connection_start_time
except (TimeoutError, ConnectionError,
imaplib.IMAP4.abort, MailboxLoginError, MailboxLogoutError,
socket.herror, socket.gaierror, socket.timeout) as e:
print(f'## Error\n{e}\n{traceback.format_exc()}\nreconnect in a minute...')
time.sleep(60)
2 changes: 1 addition & 1 deletion imap_tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
from .utils import EmailAddress
from .errors import *

__version__ = '0.50.2'
__version__ = '0.51.0'
9 changes: 8 additions & 1 deletion imap_tools/errors.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from typing import Any


class ImapToolsError(Exception):
"""Base lib error"""

Expand All @@ -9,7 +12,7 @@ class MailboxFolderStatusValueError(ImapToolsError):
class UnexpectedCommandStatusError(ImapToolsError):
"""Unexpected status in IMAP command response"""

def __init__(self, command_result: tuple, expected: str):
def __init__(self, command_result: tuple, expected: Any):
"""
:param command_result: imap command result
:param expected: expected command status
Expand Down Expand Up @@ -88,3 +91,7 @@ class MailboxFlagError(UnexpectedCommandStatusError):

class MailboxAppendError(UnexpectedCommandStatusError):
pass


class MailboxTaggedResponseError(UnexpectedCommandStatusError):
pass
128 changes: 128 additions & 0 deletions imap_tools/idle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import sys
import socket
import select
import imaplib
from typing import Optional, List

from .utils import check_command_status
from .errors import MailboxTaggedResponseError

imaplib.Commands.setdefault("IDLE", ("NONAUTH", "AUTH", "SELECTED")) # noqa

SUPPORTS_SELECT_POLL = hasattr(select, 'poll')


def get_socket_poller(sock: socket.socket, timeout: Optional[int] = None):
"""
Polls the socket for events telling us it's available to read
:param sock: socket.socket
:param timeout: seconds or None - seconds
:return: polling object
"""
if SUPPORTS_SELECT_POLL:
# select.poll allows your process to have more than 1024 file descriptors
poller = select.poll()
poller.register(sock.fileno(), select.POLLIN)
timeout = None if timeout is None else timeout * 1000
return poller.poll(timeout)
else:
# select.select fails if your process has more than 1024 file descriptors, needs for windows and some other
return select.select([sock], [], [], timeout)[0]


class IdleManager:
"""
Mailbox IDLE logic
Info about IMAP4 IDLE command at rfc2177
Workflow examples:
1.
mailbox.idle.start()
resps = mailbox.idle.poll(timeout=60)
mailbox.idle.stop()
2.
resps = mailbox.idle.wait(timeout=60)
"""

def __init__(self, mailbox):
self.mailbox = mailbox
self._idle_tag = None

def start(self):
"""Switch on mailbox IDLE mode"""
self._idle_tag = self.mailbox.box._command('IDLE') # b'KLIG3'
result = self.mailbox.box._get_response()
check_command_status((result, 'IDLE start'), MailboxTaggedResponseError, expected=None)
return self # return self in favor of context manager, expected result is None => not save it

def stop(self):
"""Switch off mailbox IDLE mode"""
self.mailbox.box.send(b"DONE\r\n")
return self.mailbox.consume_until_tagged_response(self._idle_tag)

def poll(self, timeout: Optional[float]) -> List[bytes]:
"""
Poll for IDLE responses
timeout = None
Blocks until an IDLE response is received
timeout = float
Blocks until IDLE response is received or the timeout will expire
:param timeout: seconds or None
:return: list of raw responses
result examples:
[b'* 36 EXISTS', b'* 1 RECENT']
[b'* 7 EXISTS']
"""
if timeout is not None:
timeout = float(timeout)
if timeout > 29 * 60:
raise ValueError(
'rfc2177 are advised to terminate the IDLE '
'and re-issue it at least every 29 minutes to avoid being logged off.'
)
sock = self.mailbox.box.sock
old_timeout = sock.gettimeout()
# make socket non-blocking so the timeout can be implemented for this call
sock.settimeout(None)
sock.setblocking(0)
try:
response_set = []
events = get_socket_poller(sock, timeout)
if events:
while True:
try:
line = self.mailbox.box._get_line()
except (socket.timeout, socket.error):
break
except imaplib.IMAP4.abort: # noqa
import traceback
etype, evalue, etraceback = sys.exc_info()
if "EOF" in evalue.args[0]:
break
else:
raise
else:
response_set.append(line)
return response_set
finally:
sock.setblocking(1)
if old_timeout is not None:
sock.settimeout(old_timeout)

def wait(self, timeout: Optional[float]) -> List[bytes]:
"""
Logic, step by step:
1. Start idle mode
2. Poll idle response
3. Stop idle mode
4. Return poll results
:param timeout: for poll method
:return: poll response
"""
with self.start() as idle:
return idle.poll(timeout=timeout)

def __enter__(self):
return self

def __exit__(self, exc_type, exc_value, exc_traceback):
self.stop()
21 changes: 18 additions & 3 deletions imap_tools/mailbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
import datetime
from collections import UserString
from typing import AnyStr, Optional, List, Iterable, Sequence, Union, Tuple
from email.errors import StartBoundaryNotFoundDefect, MultipartInvariantViolationDefect

from .consts import UID_PATTERN, TIMEOUT_ARG_SUPPORT_ERROR
from .message import MailMessage
from .folder import MailBoxFolderManager
from .idle import IdleManager
from .utils import clean_uids, check_command_status, chunks, encode_folder, clean_flags, decode_value
from .errors import MailboxStarttlsError, MailboxLoginError, MailboxLogoutError, MailboxNumbersError, \
MailboxFetchError, MailboxExpungeError, MailboxDeleteError, MailboxCopyError, MailboxFlagError, \
MailboxAppendError, MailboxUidsError
MailboxAppendError, MailboxUidsError, MailboxTaggedResponseError

# Maximal line length when calling readline(). This is to prevent reading arbitrary length lines.
# 20Mb is enough for search response with about 2 000 000 message numbers
Expand All @@ -26,10 +26,11 @@ class BaseMailBox:

email_message_class = MailMessage
folder_manager_class = MailBoxFolderManager
with_headers_only_allowed_errors = (StartBoundaryNotFoundDefect, MultipartInvariantViolationDefect)
idle_manager_class = IdleManager

def __init__(self):
self.folder = None # folder manager
self.idle = None # idle manager
self.login_result = None
self.box = self._get_mailbox_client()

Expand All @@ -42,11 +43,25 @@ def __exit__(self, exc_type, exc_value, exc_traceback):
def _get_mailbox_client(self) -> imaplib.IMAP4:
raise NotImplementedError

def consume_until_tagged_response(self, tag: bytes):
"""Waiting for tagged response"""
tagged_commands = self.box.tagged_commands
response_set = []
while True:
response: bytes = self.box._get_response() # noqa, example: b'IJDH3 OK IDLE Terminated'
if tagged_commands[tag]:
break
response_set.append(response)
result = tagged_commands.pop(tag)
check_command_status(result, MailboxTaggedResponseError)
return result, response_set

def login(self, username: str, password: str, initial_folder: Optional[str] = 'INBOX') -> 'BaseMailBox':
login_result = self.box._simple_command('LOGIN', username, self.box._quote(password)) # noqa
check_command_status(login_result, MailboxLoginError)
self.box.state = 'AUTH' # logic from self.box.login
self.folder = self.folder_manager_class(self)
self.idle = self.idle_manager_class(self)
if initial_folder is not None:
self.folder.set(initial_folder)
self.login_result = login_result
Expand Down
2 changes: 1 addition & 1 deletion imap_tools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def check_command_status(command_result: tuple, exception: type, expected='OK'):
"""
Check that IMAP command responses status equals <expected> status
If not, raise specified <exception>
:param command_result: imap command result
:param command_result: imap command result: tuple(typ, data)
:param exception: exception subclass of UnexpectedCommandStatusError, that raises
:param expected: expected command status
"""
Expand Down
17 changes: 17 additions & 0 deletions tests/test_idle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import unittest

from tests.utils import MailboxTestCase


class IdleTest(MailboxTestCase):

def test_idle(self):
for mailbox_name, mailbox in self.mailbox_set.items():
if mailbox.mailbox_name in ('MAIL_RU', 'YAHOO'):
continue
mailbox.idle.wait(timeout=1)
self.assertEqual(len(tuple(mailbox.fetch(limit=1))), 1)


if __name__ == "__main__":
unittest.main()

0 comments on commit f7ba30e

Please sign in to comment.