diff --git a/docs/source/conf.py b/docs/source/conf.py
index 74c3af3ff..d17e7dfe1 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 000000000..62476ba89
--- /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 270ec0bad..5975a065c 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 000000000..866a7c85e
--- /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 000000000..5da2d57ab
--- /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 000000000..118270582
--- /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 c2737dd3b..310c8b8fa 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 0371af90c..948c4a023 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'],
},
)