From 1681f8072840b9e51d49e46bdd1c49cb80c75974 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 31 Aug 2020 15:32:30 +0200 Subject: [PATCH] Added IMAP plugin and generic mail check backend [links to #146] --- docs/source/conf.py | 1 + platypush/backend/mail.py | 259 ++++++++++++++++++++++ platypush/message/__init__.py | 15 ++ platypush/message/event/mail.py | 25 +++ platypush/plugins/mail/__init__.py | 133 +++++++++++ platypush/plugins/mail/imap.py | 341 +++++++++++++++++++++++++++++ requirements.txt | 2 + setup.py | 2 + 8 files changed, 778 insertions(+) create mode 100644 platypush/backend/mail.py create mode 100644 platypush/message/event/mail.py create mode 100644 platypush/plugins/mail/__init__.py create mode 100644 platypush/plugins/mail/imap.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 74c3af3ff0..d17e7dfe1e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -262,6 +262,7 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', 'Adafruit_Python_DHT', 'RPi.GPIO', 'RPLCD', + 'imapclient', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/backend/mail.py b/platypush/backend/mail.py new file mode 100644 index 0000000000..62476ba893 --- /dev/null +++ b/platypush/backend/mail.py @@ -0,0 +1,259 @@ +import json +import os +import pathlib + +from dataclasses import dataclass +from datetime import datetime +from queue import Queue, Empty +from threading import Thread, RLock +from typing import List, Dict, Any, Optional, Tuple + +from sqlalchemy import create_engine, Column, Integer, String, DateTime +import sqlalchemy.engine as engine +from sqlalchemy.orm import sessionmaker, scoped_session +from sqlalchemy.ext.declarative import declarative_base + +from platypush.backend import Backend +from platypush.config import Config +from platypush.context import get_plugin +from platypush.message.event.mail import MailReceivedEvent, MailSeenEvent +from platypush.plugins.mail import MailInPlugin, Mail + +# +Base = declarative_base() +Session = scoped_session(sessionmaker()) + + +class MailboxStatus(Base): + """ Models the MailboxStatus table, containing information about the state of a monitored mailbox. """ + __tablename__ = 'MailboxStatus' + + mailbox_id = Column(Integer, primary_key=True) + unseen_message_ids = Column(String, default='[]') + last_checked_date = Column(DateTime) + + +# + +# +@dataclass +class Mailbox: + plugin: MailInPlugin + name: str + args: dict + + +# + + +class MailBackend(Backend): + """ + This backend can subscribe to one or multiple mail servers and trigger events when new messages are received or + messages are marked as seen. + + It requires at least one plugin that extends :class:`platypush.plugins.mail.MailInPlugin` (e.g. ``mail.imap``) to + be installed. + + Triggers: + + - :class:`platypush.message.event.mail.MailReceivedEvent` when a new message is received. + - :class:`platypush.message.event.mail.MailSeenEvent` when a message is marked as seen. + + """ + + def __init__(self, mailboxes: List[Dict[str, Any]], timeout: Optional[int] = 60, **kwargs): + """ + :param mailboxes: List of mailboxes to be monitored. Each mailbox entry contains a ``plugin`` attribute to + identify the :class:`platypush.plugins.mail.MailInPlugin` plugin that will be used (e.g. ``mail.imap``) + and the arguments that will be passed to :meth:`platypush.plugins.mail.MailInPlugin.search_unseen_messages`. + The ``name`` parameter can be used to identify this mailbox in the relevant events, otherwise + ``Mailbox #{id}`` will be used as a name. Example configuration: + + .. code-block:: yaml + + backend.mail: + mailboxes: + - plugin: mail.imap + name: "My Local Server" + username: me@mydomain.com + password: my-imap-password + server: localhost + ssl: true + folder: "All Mail" + + - plugin: mail.imap + name: "GMail" + username: me@gmail.com + password: my-google-password + server: imap.gmail.com + ssl: true + folder: "INBOX" + + If you have a default configuration available for a mail plugin you can implicitly reuse it without + replicating it here. Example: + + .. code-block:: yaml + + mail.imap: + username: me@mydomain.com + password: my-imap-password + server: localhost + ssl: true + + backend.mail: + mailboxes: + # The mail.imap default configuration will be used + - plugin: mail.imap + name: "My Local Server" + folder: "All Mail" + + :param timeout: Connect/read timeout for a mailbox, in seconds (default: 60). + """ + self.logger.info('Initializing mail backend') + + super().__init__(**kwargs) + self.mailboxes: List[Mailbox] = [] + self.timeout = timeout + self._unread_msgs: List[Dict[int, Mail]] = [{}] * len(mailboxes) + self._db_lock = RLock() + self.workdir = os.path.join(os.path.expanduser(Config.get('workdir')), 'mail') + self.dbfile = os.path.join(self.workdir, 'backend.db') + + # Parse mailboxes + for i, mbox in enumerate(mailboxes): + assert 'plugin' in mbox, 'No plugin attribute specified for mailbox n.{}'.format(i) + plugin = get_plugin(mbox.pop('plugin')) + assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format(plugin) + name = mbox.pop('name') if 'name' in mbox else 'Mailbox #{}'.format(i + 1) + self.mailboxes.append(Mailbox(plugin=plugin, name=name, args=mbox)) + + # Configure/sync db + pathlib.Path(self.workdir).mkdir(parents=True, exist_ok=True, mode=0o750) + self._db = self._db_get_engine() + Base.metadata.create_all(self._db) + Session.configure(bind=self._db) + self._db_sync_mailboxes() + self.logger.info('Mail backend initialized') + + # + def _db_get_engine(self) -> engine.Engine: + return create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False}) + + def _db_sync_mailboxes(self) -> None: + mailbox_ids = list(range(len(self.mailboxes))) + + with self._db_lock: + session = Session() + records = { + record.mailbox_id: record + for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all() + } + + for mbox_id, mbox in enumerate(self.mailboxes): + if mbox_id not in records: + record = MailboxStatus(mailbox_id=mbox_id, unseen_message_ids='[]') + session.add(record) + else: + record = records[mbox_id] + + unseen_msg_ids = json.loads(record.unseen_message_ids or '[]') + self._unread_msgs[mbox_id] = {msg_id: {} for msg_id in unseen_msg_ids} + + session.commit() + + def _db_get_mailbox_status(self, mailbox_ids: List[int]) -> Dict[int, MailboxStatus]: + with self._db_lock: + session = Session() + return { + record.mailbox_id: record + for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all() + } + + # + + # + @staticmethod + def _check_thread(q: Queue, plugin: MailInPlugin, **args): + def thread(): + # noinspection PyUnresolvedReferences + unread = plugin.search_unseen_messages(**args).output + q.put({msg.id: msg for msg in unread}) + + return thread + + def _get_unread_seen_msgs(self, mailbox_idx: int, unread_msgs: Dict[int, Mail]) \ + -> Tuple[Dict[int, Mail], Dict[int, Mail]]: + prev_unread_msgs = self._unread_msgs[mailbox_idx] + + return { + msg_id: unread_msgs[msg_id] + for msg_id in unread_msgs + if msg_id not in prev_unread_msgs + }, { + msg_id: prev_unread_msgs[msg_id] + for msg_id in prev_unread_msgs + if msg_id not in unread_msgs + } + + def _process_msg_events(self, mailbox_id: int, unread: List[Mail], seen: List[Mail], + last_checked_date: Optional[datetime] = None): + for msg in unread: + if msg.date and last_checked_date and msg.date < last_checked_date: + continue + self.bus.post(MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, msg=msg)) + for msg in seen: + self.bus.post(MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, msg=msg)) + + def _check_mailboxes(self) -> List[Dict[int, Mail]]: + workers = [] + queues = [] + results = [] + + for mbox in self.mailboxes: + q = Queue() + worker = Thread(target=self._check_thread(q, plugin=mbox.plugin, **mbox.args)) + worker.start() + workers.append(worker) + queues.append(q) + + for worker in workers: + worker.join(timeout=self.timeout) + + for i, q in enumerate(queues): + try: + unread = q.get(timeout=self.timeout) + results.append(unread) + except Empty: + self.logger.warning('Checks on mailbox #{} timed out after {} seconds'.format(i + 1, self.timeout)) + continue + + return results + + # + + # + def loop(self): + records = [] + mailbox_statuses = self._db_get_mailbox_status(list(range(len(self.mailboxes)))) + results = self._check_mailboxes() + + for i, unread in enumerate(results): + unread_msgs, seen_msgs = self._get_unread_seen_msgs(i, unread) + self._process_msg_events(i, unread=list(unread_msgs.values()), seen=list(seen_msgs.values()), + last_checked_date=mailbox_statuses[i].last_checked_date) + + self._unread_msgs[i] = unread + records.append(MailboxStatus(mailbox_id=i, + unseen_message_ids=json.dumps([msg_id for msg_id in unread.keys()]), + last_checked_date=datetime.now())) + + with self._db_lock: + session = Session() + for record in records: + session.merge(record) + session.commit() + + # + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/__init__.py b/platypush/message/__init__.py index 270ec0bad2..5975a065c1 100644 --- a/platypush/message/__init__.py +++ b/platypush/message/__init__.py @@ -1,12 +1,24 @@ +from abc import ABC, abstractmethod import datetime import logging import inspect import json import time +from typing import Union logger = logging.getLogger(__name__) +class JSONAble(ABC): + """ + Generic interface for JSON-able objects. + """ + + @abstractmethod + def to_json(self) -> Union[str, list, dict]: + raise NotImplementedError() + + class Message(object): """ Message generic class """ @@ -48,6 +60,9 @@ class Message(object): if value is not None: return value + if isinstance(obj, JSONAble): + return obj.to_json() + return super().default(obj) def __init__(self, timestamp=None, *args, **kwargs): diff --git a/platypush/message/event/mail.py b/platypush/message/event/mail.py new file mode 100644 index 0000000000..866a7c85e4 --- /dev/null +++ b/platypush/message/event/mail.py @@ -0,0 +1,25 @@ +from typing import Optional, Dict + +from platypush.message.event import Event + + +class MailEvent(Event): + def __init__(self, mailbox: str, message: Optional[Dict] = None, *args, **kwargs): + super().__init__(*args, mailbox=mailbox, message=message or {}, **kwargs) + + +class MailReceivedEvent(MailEvent): + """ + Triggered when a new email is received. + """ + pass + + +class MailSeenEvent(MailEvent): + """ + Triggered when a previously unseen email is seen. + """ + pass + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/mail/__init__.py b/platypush/plugins/mail/__init__.py new file mode 100644 index 0000000000..5da2d57ab6 --- /dev/null +++ b/platypush/plugins/mail/__init__.py @@ -0,0 +1,133 @@ +import inspect +import os +import subprocess + +from abc import ABC +from dataclasses import dataclass +from datetime import datetime +from typing import Optional, List, Union, Any, Dict + +from platypush.message import JSONAble +from platypush.plugins import Plugin, action + + +@dataclass +class ServerInfo: + server: str + port: int + username: Optional[str] + password: Optional[str] + ssl: bool + keyfile: Optional[str] + certfile: Optional[str] + access_token: Optional[str] + oauth_mechanism: Optional[str] + oauth_vendor: Optional[str] + timeout: Optional[int] + + +class Mail(JSONAble): + def __init__(self, id: int, date: datetime, size: int, + from_: Optional[Union[Dict[str, str], List[str]]] = None, + to: Optional[Union[Dict[str, str], List[str]]] = None, + cc: Optional[Union[Dict[str, str], List[str]]] = None, + bcc: Optional[Union[Dict[str, str], List[str]]] = None, subject: str = '', + payload: Optional[Any] = None, **kwargs): + self.id = id + self.date = date + self.size = size + self.from_ = from_ or kwargs.get('from') + self.to = to + self.cc = cc or [] + self.bcc = bcc or [] + self.subject = subject + self.payload = payload + + for k, v in kwargs.items(): + setattr(self, k, v) + + def to_json(self) -> dict: + return { + k if k != 'from_' else 'from': v + for k, v in dict(inspect.getmembers(self)).items() + if not k.startswith('_') and not callable(v) + } + + +class MailPlugin(Plugin, ABC): + """ + Base class for mail plugins. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.server_info: Optional[ServerInfo] = None + + @staticmethod + def _get_password(password: Optional[str] = None, password_cmd: Optional[str] = None) -> Optional[str]: + """ + Get the password either from a provided string or from a password command. + """ + if not password_cmd: + return password + + proc = subprocess.Popen(['sh', '-c', password_cmd], stdout=subprocess.PIPE) + password = proc.communicate()[0].decode() + return password or None + + @staticmethod + def _get_path(path: str) -> str: + return os.path.abspath(os.path.expanduser(path)) + + def _get_server_info(self, server: Optional[str] = None, port: Optional[int] = None, username: Optional[str] = None, + password: Optional[str] = None, password_cmd: Optional[str] = None, + ssl: Optional[bool] = None, keyfile: Optional[str] = None, certfile: Optional[str] = None, + access_token: Optional[str] = None, oauth_mechanism: Optional[str] = None, + oauth_vendor: Optional[str] = None, default_port: Optional[int] = None, + default_ssl_port: Optional[int] = None, timeout: Optional[int] = None, **kwargs) \ + -> ServerInfo: + if not port: + port = default_ssl_port if ssl else default_port + + info = ServerInfo(server=server, port=port, username=username, + password=self._get_password(password, password_cmd), ssl=ssl, keyfile=keyfile, + certfile=certfile, access_token=access_token, oauth_mechanism=oauth_mechanism, + oauth_vendor=oauth_vendor, timeout=timeout) + + if server: + return info + + if self.server_info: + assert self.server_info.server, 'No server specified' + return self.server_info + + return info + + +class MailInPlugin(MailPlugin, ABC): + """ + Base class for mail in plugins. + """ + + @action + def get_folders(self) -> list: + raise NotImplementedError() + + @action + def get_sub_folders(self) -> list: + raise NotImplementedError() + + @action + def search(self, criteria: str, directory: Optional[str] = None) -> list: + raise NotImplementedError() + + @action + def search_unseen_messages(self, directory: Optional[str] = None) -> list: + raise NotImplementedError() + + @action + def get_message(self, id) -> dict: + raise NotImplementedError() + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/mail/imap.py b/platypush/plugins/mail/imap.py new file mode 100644 index 0000000000..1182705823 --- /dev/null +++ b/platypush/plugins/mail/imap.py @@ -0,0 +1,341 @@ +import email +from typing import Optional, List, Dict, Union, Any, Tuple + +from imapclient import IMAPClient +from imapclient.response_types import Address + +from platypush.plugins import action +from platypush.plugins.mail import MailInPlugin, ServerInfo, Mail + + +class MailImapPlugin(MailInPlugin): + """ + Plugin to interact with a mail server over IMAP. + + Requires: + + * **imapclient** (``pip install imapclient``) + + """ + + _default_port = 143 + _default_ssl_port = 993 + + def __init__(self, server: str, port: Optional[int] = None, username: Optional[str] = None, + password: Optional[str] = None, password_cmd: Optional[str] = None, access_token: Optional[str] = None, + oauth_mechanism: Optional[str] = 'XOAUTH2', oauth_vendor: Optional[str] = None, ssl: bool = False, + keyfile: Optional[str] = None, certfile: Optional[str] = None, timeout: Optional[int] = 60, **kwargs): + """ + :param server: Server name/address. + :param port: Port (default: 143 for plain, 993 for SSL). + :param username: IMAP username. + :param password: IMAP password. + :param password_cmd: If you don't want to input your password in the configuration, run this command to fetch + or decrypt the password. + :param access_token: OAuth2 access token if the server supports OAuth authentication. + :param oauth_mechanism: OAuth2 mechanism (default: ``XOAUTH2``). + :param oauth_vendor: OAuth2 vendor (default: None). + :param ssl: Use SSL (default: False). + :param keyfile: Private key file for SSL connection if client authentication is required. + :param certfile: SSL certificate file or chain. + :param timeout: Server connect/read timeout in seconds (default: 60). + """ + super().__init__(**kwargs) + self.server_info = self._get_server_info(server=server, port=port, username=username, password=password, + password_cmd=password_cmd, ssl=ssl, keyfile=keyfile, certfile=certfile, + access_token=access_token, oauth_mechanism=oauth_mechanism, + oauth_vendor=oauth_vendor, timeout=timeout) + + def _get_server_info(self, server: Optional[str] = None, port: Optional[int] = None, username: Optional[str] = None, + password: Optional[str] = None, password_cmd: Optional[str] = None, + access_token: Optional[str] = None, oauth_mechanism: Optional[str] = None, + oauth_vendor: Optional[str] = None, ssl: Optional[bool] = None, keyfile: Optional[str] = None, + certfile: Optional[str] = None, timeout: Optional[int] = None, **kwargs) -> ServerInfo: + return super()._get_server_info(server=server, port=port, username=username, password=password, + password_cmd=password_cmd, ssl=ssl, keyfile=keyfile, certfile=certfile, + default_port=self._default_port, default_ssl_port=self._default_ssl_port, + access_token=access_token, oauth_mechanism=oauth_mechanism, + oauth_vendor=oauth_vendor, timeout=timeout) + + def connect(self, **connect_args) -> IMAPClient: + info = self._get_server_info(**connect_args) + self.logger.info('Connecting to {}'.format(info.server)) + context = None + + if info.ssl: + import ssl + context = ssl.create_default_context() + context.load_cert_chain(certfile=info.certfile, keyfile=info.keyfile) + + client = IMAPClient(host=info.server, port=info.port, ssl=info.ssl, ssl_context=context) + if info.password: + client.login(info.username, info.password) + elif info.access_token: + client.oauth2_login(info.username, access_token=info.access_token, mech=info.oauth_mechanism, + vendor=info.oauth_vendor) + + return client + + @staticmethod + def _get_folders(data: List[tuple]) -> List[Dict[str, str]]: + folders = [] + for line in data: + (flags), delimiter, mailbox_name = line + folders.append({ + 'name': mailbox_name, + 'flags': [flag.decode() for flag in flags], + 'delimiter': delimiter.decode(), + }) + + return folders + + @action + def get_folders(self, folder: str = '', pattern: str = '*', **connect_args) -> List[Dict[str, str]]: + """ + Get the list of all the folders hosted on the server or those matching a pattern. + + :param folder: Base folder (default: root). + :param pattern: Pattern to search (default: None). + :param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override. + :return: Example: + + .. code-block:: json + + [ + { + "name": "INBOX", + "flags": "\\Noinferiors", + "delimiter": "/" + }, + { + "name": "Archive", + "flags": "\\Noinferiors", + "delimiter": "/" + }, + { + "name": "Spam", + "flags": "\\Noinferiors", + "delimiter": "/" + } + ] + + """ + with self.connect(**connect_args) as client: + data = client.list_folders(directory=folder, pattern=pattern) + + return self._get_folders(data) + + @action + def get_sub_folders(self, folder: str = '', pattern: str = '*', **connect_args) -> List[Dict[str, str]]: + """ + Get the list of all the sub-folders hosted on the server or those matching a pattern. + + :param folder: Base folder (default: root). + :param pattern: Pattern to search (default: None). + :param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override. + :return: Example: + + .. code-block:: json + + [ + { + "name": "INBOX", + "flags": "\\Noinferiors", + "delimiter": "/" + }, + { + "name": "Archive", + "flags": "\\Noinferiors", + "delimiter": "/" + }, + { + "name": "Spam", + "flags": "\\Noinferiors", + "delimiter": "/" + } + ] + + """ + with self.connect(**connect_args) as client: + data = client.list_sub_folders(directory=folder, pattern=pattern) + + return self._get_folders(data) + + @staticmethod + def _parse_address(imap_addr: Address) -> Dict[str, str]: + return { + 'name': imap_addr.name.decode() if imap_addr.name else None, + 'route': imap_addr.route.decode() if imap_addr.route else None, + 'email': '{name}@{host}'.format(name=imap_addr.mailbox.decode(), host=imap_addr.host.decode()) + } + + @classmethod + def _parse_addresses(cls, addresses: Optional[Tuple[Address]] = None) -> Dict[str, Dict[str, str]]: + ret = {} + if addresses: + for addr in addresses: + addr = cls._parse_address(addr) + ret[addr['email']] = addr + + return ret + + @classmethod + def _parse_message(cls, msg_id: int, imap_msg: Dict[bytes, Any]) -> Mail: + message = { + 'id': msg_id, + 'seq': imap_msg[b'SEQ'], + } + + if imap_msg.get(b'FLAGS'): + message['flags'] = [flag.decode() for flag in imap_msg[b'FLAGS'] if flag] + if b'INTERNALDATE' in imap_msg: + message['internal_date'] = imap_msg[b'INTERNALDATE'] + if b'RFC822.SIZE' in imap_msg: + message['size'] = imap_msg[b'RFC822.SIZE'] + if b'ENVELOPE' in imap_msg: + envelope = imap_msg[b'ENVELOPE'] + message['bcc'] = cls._parse_addresses(envelope.bcc) + message['cc'] = cls._parse_addresses(envelope.cc) + message['date'] = envelope.date + message['from'] = cls._parse_addresses(envelope.from_) + message['message_id'] = envelope.message_id.decode() if envelope.message_id else None + message['in_reply_to'] = envelope.in_reply_to.decode() if envelope.in_reply_to else None + message['reply_to'] = cls._parse_addresses(envelope.reply_to) + message['sender'] = cls._parse_addresses(envelope.sender) + message['subject'] = envelope.subject.decode() if envelope.subject else None + message['to'] = cls._parse_addresses(envelope.to) + + return Mail(**message) + + @action + def search(self, criteria: Union[str, List[str]] = 'ALL', folder: str = 'INBOX', + attributes: Optional[List[str]] = None, **connect_args) -> List[Mail]: + """ + Search for messages on the server that fit the specified criteria. + + :param criteria: It should be a sequence of one or more criteria items. Each criteria item may be either unicode + or bytes (default: ``ALL``). Example values:: + + ['UNSEEN'] + ['SMALLER', 500] + ['NOT', 'DELETED'] + ['TEXT', 'foo bar', 'FLAGGED', 'SUBJECT', 'baz'] + ['SINCE', '2020-03-14T12:13:45+00:00'] + + It is also possible (but not recommended) to pass the combined criteria as a single string. In this case + IMAPClient won't perform quoting, allowing lower-level specification of criteria. Examples of this style:: + + 'UNSEEN' + 'SMALLER 500' + 'NOT DELETED' + 'TEXT "foo bar" FLAGGED SUBJECT "baz"' + 'SINCE 03-Apr-2005' + + To support complex search expressions, criteria lists can be nested. The following will match messages that + are both not flagged and do not have "foo" in the subject:: + + ['NOT', ['SUBJECT', 'foo', 'FLAGGED']] + + :param folder: Folder to search (default: ``INBOX``). + :param attributes: Attributes that should be retrieved, according to + `RFC 3501 `_ + (default: ``ALL`` = ``[FLAGS INTERNALDATE RFC822.SIZE ENVELOPE]``). + Note that ``BODY`` will be ignored if specified here for performance reasons - use :meth:`.get_message` if + you want to get the full content of a message known its ID from :meth:`.search`. + + :param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override. + :return: List of messages matching the criteria. Example: + + .. code-block:: json + + [ + { + "id": 702, + "seq": 671, + "flags": [ + "nonjunk" + ], + "internal_date": "2020-08-30T00:31:52+00:00", + "size": 2908738, + "bcc": {}, + "cc": {}, + "date": "2020-08-30T00:31:52+00:00", + "from": { + "test123@gmail.com": { + "name": "A test", + "route": null, + "email": "test123@gmail.com" + } + }, + "message_id": "", + "in_reply_to": "", + "reply_to": {}, + "sender": { + "test123@gmail.com": { + "name": "A test", + "route": null, + "email": "test123@gmail.com" + } + }, + "subject": "Test email", + "to": { + "me@gmail.com": { + "name": null, + "route": null, + "email": "me@gmail.com" + } + } + } + ] + + """ + if not attributes: + attributes = ['ALL'] + else: + attributes = [attr.upper() for attr in attributes] + + data = {} + with self.connect(**connect_args) as client: + client.select_folder(folder, readonly=True) + ids = client.search(criteria) + if len(ids): + data = client.fetch(list(ids), attributes) + + messages = [ + self._parse_message(msg_id, data[msg_id]) + for msg_id in sorted(data.keys()) + ] + + return messages + + @action + def search_unseen_messages(self, folder: str = 'INBOX', **connect_args) -> List[Mail]: + """ + Shortcut for :meth:`.search` that returns only the unread messages. + """ + return self.search(criteria='UNSEEN', directory=folder, attributes=['ALL'], **connect_args) + + @action + def get_message(self, id: int, folder: str = 'INBOX', **connect_args) -> Mail: + """ + Get the full content of a message given the ID returned by :meth:`.search`. + + :param id: Message ID. + :param folder: Folder name (default: ``INBOX``). + :param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override. + :return: A message in the same format as :meth:`.search`, with an added ``payload`` attribute containing the + body/payload. + """ + with self.connect(**connect_args) as client: + client.select_folder(folder, readonly=True) + data = client.fetch(id, ['ALL', 'RFC822']) + assert id in data, 'No such message ID: {}'.format(id) + + data = data[id] + ret = self._parse_message(id, data) + msg = email.message_from_bytes(data[b'RFC822']) + ret.payload = msg.get_payload() + + return ret + +# vim:sw=4:ts=4:et: diff --git a/requirements.txt b/requirements.txt index c2737dd3ba..310c8b8fa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -293,3 +293,5 @@ croniter # RPi.GPIO # RPLCD +# Support for IMAP mail integration +# imapclient diff --git a/setup.py b/setup.py index 0371af90c2..948c4a023d 100755 --- a/setup.py +++ b/setup.py @@ -330,5 +330,7 @@ setup( 'dht': ['Adafruit_Python_DHT @ git+https://github.com/adafruit/Adafruit_Python_DHT'], # Support for LCD display integration 'lcd': ['RPi.GPIO', 'RPLCD'], + # Support for IMAP mail integration + 'imap': ['imapclient'], }, )