diff --git a/doc/src/.gitignore b/doc/src/.gitignore new file mode 100644 index 0000000..6de837b --- /dev/null +++ b/doc/src/.gitignore @@ -0,0 +1 @@ +_autosummary/ diff --git a/doc/src/_templates/autosummary/class.rst b/doc/src/_templates/autosummary/class.rst new file mode 100644 index 0000000..97b2730 --- /dev/null +++ b/doc/src/_templates/autosummary/class.rst @@ -0,0 +1,11 @@ +{{ fullname | escape | underline }} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :members: + :undoc-members: + :special-members: + :show-inheritance: + + .. autoclasstoc:: diff --git a/doc/src/api.rst b/doc/src/api.rst new file mode 100644 index 0000000..60683f8 --- /dev/null +++ b/doc/src/api.rst @@ -0,0 +1,8 @@ +API reference +============= + +.. autosummary:: + :toctree: _autosummary + :recursive: + + remote_email_filtering diff --git a/doc/src/conf.py b/doc/src/conf.py index 516c522..c2725ee 100644 --- a/doc/src/conf.py +++ b/doc/src/conf.py @@ -25,10 +25,21 @@ extensions = [ 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', 'sphinx.ext.doctest', 'sphinx.ext.todo', 'sphinx.ext.coverage', ] +autosummary_generate = True +autosummary_imported_members = True + +autoclass_content = 'both' +autodoc_default_options = { + 'members': True, + 'special-members': '__init__,__call__', + 'undoc-members': False, + 'show-inheritance': True +} templates_path = ['_templates'] exclude_patterns = [] @@ -37,7 +48,7 @@ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'alabaster' +html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] # -- Options for todo extension ---------------------------------------------- diff --git a/doc/src/getting_started.rst b/doc/src/getting_started.rst new file mode 100644 index 0000000..7489d87 --- /dev/null +++ b/doc/src/getting_started.rst @@ -0,0 +1,4 @@ +Getting started +--------------- + +Just do it! diff --git a/doc/src/index.rst b/doc/src/index.rst index cf8dfda..dd881d2 100644 --- a/doc/src/index.rst +++ b/doc/src/index.rst @@ -2,25 +2,9 @@ Welcome to remote-email-filtering's documentation! ================================================== .. toctree:: - :maxdepth: 2 :caption: Contents: + :maxdepth: 4 - - -Remote ------- -.. automodule:: remote_email_filtering.remote - :members: - -Action ------- -.. automodule:: remote_email_filtering.action - :members: - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + introduction + getting_started + api diff --git a/doc/src/introduction.rst b/doc/src/introduction.rst new file mode 100644 index 0000000..0d4bcc8 --- /dev/null +++ b/doc/src/introduction.rst @@ -0,0 +1,10 @@ +Introduction +============ + +``remote_email_filtering`` is a email client library that lets you use arbitrary +Python code for automation. You can write rules or filters as code on any +machine instead of being restricted to limited rulsets in hosted webmail or GUI +email clients. + +As these actions are Python code, you can trigger other automation based on +emails. diff --git a/src/remote_email_filtering/__init__.py b/src/remote_email_filtering/__init__.py index 532e83a..edb6663 100644 --- a/src/remote_email_filtering/__init__.py +++ b/src/remote_email_filtering/__init__.py @@ -1,3 +1,4 @@ __version__ = '0.1.0' -from .main import * +from .types import * +from .main import start diff --git a/src/remote_email_filtering/action.py b/src/remote_email_filtering/action.py index 59ddee5..39d78bd 100644 --- a/src/remote_email_filtering/action.py +++ b/src/remote_email_filtering/action.py @@ -1,12 +1,53 @@ +""" +Basic building blocks for doing stuff with a +:class:`~remote_email_filtering.message.Message` +""" + import abc class Action(abc.ABC): - """A callable that does something with a Message""" + """ + A callable that does something with a :class:`~.message.Message` + + Instances will be called with :class:`~.message.Message` instances. + The :attr:`remote` attribute will be set to a :class:`Remote` before + calling. + + """ def __init__(self): self.remote = None @abc.abstractmethod - def __call__(self, message): + def __call__(self, msg) -> 'Iterable[Action]': + """ + When called, an :class:`Action` should return an iterable of other + :class:`Action` s that will be applied to the ``msg`` being processed. + """ pass + + +class Stop(Action): + """ + Stop processing any futher :class:`Action` for the current + :class:`~.message.Message` + """ + + def __call__(self, msg): + raise StopIteration() + + +class Move(Action): + def __init__(self, destination: tuple[str]): + """ + Move the :class:`~.message.Message` to ``destination`` directory. + + :param tuple[str] destination: the destination directory on the server + """ + super().__init__() + self.destination = destination + + def __call__(self, msg): + self.remote.move_message(msg, self.destination) + return [] diff --git a/src/remote_email_filtering/main.py b/src/remote_email_filtering/main.py index 50384e3..fe8a69c 100644 --- a/src/remote_email_filtering/main.py +++ b/src/remote_email_filtering/main.py @@ -1,19 +1,47 @@ import datetime +import itertools import time -from pprint import pp +import typing +from . import types -def filter_message(message, filters): - pass - -def start_filtering(remote, - filter_map=dict(), - interval=datetime.timedelta(seconds=5)): +def pipeline(message, actions): + actions = iter(actions) while True: - for dir in remote.list_dirs(): - if dir in filter_map: - for message in remote.get_messages(dir): - filter_message(message, filter_map[dir]) + try: + action = next(actions) + except StopIteration: + break + + action.remote = message.remote + try: + further = action(message) + if further is None: + raise Exception(msg=f'Action: {action} returned None') + except StopIteration: + return + actions = itertools.chain(further, actions) + + +def start(remote, + dir_actions: typing.Dict[types.Directory, typing.List['Action']] = dict(), + interval=datetime.timedelta(seconds=5), + count=float('inf')): + """ + Start applying :class:`~.action.Action` s to all messages in specified + directories. + + :param Dict[Directory, List[Action]] dir_actions: + :class:`~.action.Action` to apply to all messages in the directory + :param interval: duration to wait after each pass + :param count: number of times to loop through all directories + """ + while count > 0: + for dir_ in remote.list_dirs(): + if dir_ in dir_actions: + for message in remote.get_messages(dir_): + pipeline(message, dir_actions[dir_]) + count -= 1 time.sleep(interval.seconds) diff --git a/src/remote_email_filtering/message.py b/src/remote_email_filtering/message.py index d4099a1..8123cf7 100644 --- a/src/remote_email_filtering/message.py +++ b/src/remote_email_filtering/message.py @@ -1,31 +1,69 @@ import email import email.header +import email.policy +import typing +from . import types class Message(object): - def __init__(self, rfc822_bytes): + """ + An email message with convenient properties + """ + def __init__(self, uid: types.Uid, + envelope, remote, dir_=None, rfc822_bytes=None): + """ + :param uid: A unique identifier for a message within ``dir_`` + :param envelope: The envelope structure parsed from headers + :param remote: A :class:`~.remote.Remote` used to lazy-load the body + :param tuple[str] dir_: the mailbox directory that this email is in + """ + self.uid = uid + self.envelope = envelope._asdict() + for field in ('cc', 'bcc', 'from_', 'reply_to', 'sender', 'to'): + if not self.envelope[field]: + self.envelope[field] = [] + self.envelope[field] = tuple((types.Address.from_imapclient(x) + for x in self.envelope[field])) + + self.remote = remote + self.dir_ = dir_ self.raw = rfc822_bytes - self.mail = email.message_from_bytes(self.raw) + self._body = None + if rfc822_bytes is not None: + self._body = email.message_from_bytes(self.raw, + policy=email.policy.default) + + @property + def body(self): + if self._body is None: + self.raw = self.remote.fetch_body(self.uid) + self._body = email.message_from_bytes(self.raw, + policy=email.policy.default) + return self._body @property def To(self): - ret = self.mail['To'] - if ret is None: - ret = '' - return ret + return self.envelope['to'] + + @property + def Cc(self): + return self.envelope['cc'] + + @property + def From(self): + return self.envelope['from_'] @property def Recipients(self): - return ', '.join(_ for _ in (self.mail['To'], self.mail['CC']) - if _ is not None) + return self.To + self.Cc @property def Subject(self): - return self.mail['Subject'] + return self.envelope['subject'] @property - def SaneSubject(self): - ret = self.mail['Subject'] + def _SaneSubject(self): + ret = self.body['Subject'] ret = email.header.decode_header(ret)[0] if ret[1] is not None: ret = ret[0].decode('ascii', errors='replace') @@ -33,7 +71,3 @@ def SaneSubject(self): ret = ret[0] ret = ret.replace('\n', '') return ret - - @property - def From(self): - return self.mail['From'] diff --git a/src/remote_email_filtering/remote.py b/src/remote_email_filtering/remote.py index 225f71f..e383610 100644 --- a/src/remote_email_filtering/remote.py +++ b/src/remote_email_filtering/remote.py @@ -1,26 +1,64 @@ import abc import imapclient +import typing +from . import types from . import message class Remote(abc.ABC): @abc.abstractmethod - def list_dirs(self): + def list_dirs(self) -> typing.Iterable[types.Directory]: + """ + List all ``Directory`` in the mailbox. + """ + pass + + @abc.abstractmethod + def list_messages(self, dir_: types.Directory) -> types.Uid: + """ + List unique identifiers for all messages in ``dir_``. These identifiers + must be unique for the entire mailbox. + """ + pass + + @abc.abstractmethod + def fetch_envelope(self, msg_id: types.Uid): + """ + Fetch the envelope parsed from the email headers. + """ pass @abc.abstractmethod - def list_message_ids(self, dir): + def fetch_body(self, msg_id: types.Uid): + """ + Fetch the full email body. + """ pass + def get_messages(self, dir_: types.Directory) -> typing.Iterable[message.Message]: + """ + Get all messages in ``dir_`` + """ + for msg_id in self.list_messages(dir_): + envelope = self.fetch_envelope(msg_id) + yield message.Message(uid=msg_id, envelope=envelope, + dir_=dir_, remote=self) + @abc.abstractmethod - def fetch_message(self, message_id): + def move_message_id(self, msg_id: types.Uid, target_dir: types.Directory) -> types.Uid: + """ + Move ``msg_id`` to ``target_dir``. + """ pass - def get_messages(self, dir): - for msg_id in self.list_message_ids(dir): - message = self.fetch_message(msg_id) - yield message + def move_message(self, msg: message.Message, target_dir: types.Directory): + """ + Move ``msg`` to ``taget_dir``. + """ + new_uid = self.move_message_id(msg.uid, target_dir) + msg.dir_ = target_dir + msg.uid = new_uid class Imap(Remote): @@ -34,13 +72,29 @@ def list_dirs(self): name_components = tuple(name.split(delim.decode())) yield name_components - def list_message_ids(self, dir): - self.connection.select_folder('/'.join(dir)) - return self.connection.search() + def list_messages(self, dir_): + self.connection.select_folder('/'.join(dir_)) + for uid in self.connection.search(): + # IMAP message uid are unique only within the directory. Create a + # composite uid that contains the directory. + yield (dir_, uid) + + def fetch_envelope(self, msg_id): + dir_, uid = msg_id + self.connection.select_folder('/'.join(dir_)) + ret = self.connection.fetch(uid, ['UID', 'ENVELOPE']) + msg = ret[uid] + return msg[b'ENVELOPE'] + + def fetch_body(self, msg_id): + dir_, uid = msg_id + self.connection.select_folder('/'.join(dir_)) + ret = self.connection.fetch(uid, ['UID', 'RFC822']) + msg = ret[uid] + return msg[b'RFC822'] - def fetch_message(self, msg_id): - msg = self.connection.fetch(msg_id, ['FLAGS', 'INTERNALDATE', - 'ENVELOPE', 'RFC822']) - msg = msg[msg_id] - msg = message.Message(msg[b'RFC822']) - return msg + def move_message_id(self, msg_id, target_dir): + dir_, uid = msg_id + self.connection.select_folder('/'.join(dir_)) + self.connection.move([uid], '/'.join(target_dir)) + return (target_dir, uid) diff --git a/src/remote_email_filtering/types.py b/src/remote_email_filtering/types.py new file mode 100644 index 0000000..695d0c4 --- /dev/null +++ b/src/remote_email_filtering/types.py @@ -0,0 +1,29 @@ +import re +import collections +import typing + + +class Address(collections.namedtuple('Address', 'name mailbox host', + defaults=(None, None, None))): + @classmethod + def from_imapclient(cls, addr): + return cls(name=addr.name, mailbox=addr.mailbox, host=addr.host) + + def re_match(self, addr): + sname, smbox, shost = self + if sname is None: + sname = b'' + + if any(x is None for x in (addr.name, addr.mailbox, addr.host)): + return False + return all((re.fullmatch(addr.name, sname), + re.fullmatch(addr.mailbox, smbox), + re.fullmatch(addr.host, shost))) + + +""" +A directory on the mail server made up of the path components of the directory +""" +Directory = typing.Tuple[str] + +Uid = typing.Hashable diff --git a/utils/list_dirs.py b/utils/list_dirs.py index a497445..d3e8c01 100755 --- a/utils/list_dirs.py +++ b/utils/list_dirs.py @@ -1,16 +1,25 @@ #!/usr/bin/env python3 import json import os +from pprint import pp import sys sys.path.insert(0, os.path.abspath('../src')) +import remote_email_filtering +import remote_email_filtering.remote +import remote_email_filtering.main +import remote_email_filtering.action +from remote_email_filtering.action import Action, Stop + + +def print_envelope(msg): + pp(msg.envelope) + return [] + def main(): import argparse - import remote_email_filtering - import remote_email_filtering.remote - import remote_email_filtering.main parser = argparse.ArgumentParser( description="List top level directories over IMAP") @@ -27,10 +36,10 @@ def main(): user=args.USER, token=creds['token']) filters = { - ('INBOX',): [lambda x: x, ], + ('INBOX',): [print_envelope, Stop()], } - remote_email_filtering.main.start_filtering(remote, filters) + remote_email_filtering.main.start(remote, filters, count=1) if __name__ == '__main__':