Added IMAP plugin and generic mail check backend [links to #146]
This commit is contained in:
parent
f1ab923bfe
commit
1681f80728
8 changed files with 778 additions and 0 deletions
|
@ -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('../..'))
|
||||
|
|
259
platypush/backend/mail.py
Normal file
259
platypush/backend/mail.py
Normal file
|
@ -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
|
||||
|
||||
# <editor-fold desc="Database tables">
|
||||
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)
|
||||
|
||||
|
||||
# </editor-fold>
|
||||
|
||||
# <editor-fold desc="Mailbox model">
|
||||
@dataclass
|
||||
class Mailbox:
|
||||
plugin: MailInPlugin
|
||||
name: str
|
||||
args: dict
|
||||
|
||||
|
||||
# </editor-fold>
|
||||
|
||||
|
||||
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')
|
||||
|
||||
# <editor-fold desc="Database methods">
|
||||
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()
|
||||
}
|
||||
|
||||
# </editor-fold>
|
||||
|
||||
# <editor-fold desc="Parse unread messages logic">
|
||||
@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
|
||||
|
||||
# </editor-fold>
|
||||
|
||||
# <editor-fold desc="Loop function">
|
||||
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()
|
||||
|
||||
# </editor-fold>
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -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):
|
||||
|
|
25
platypush/message/event/mail.py
Normal file
25
platypush/message/event/mail.py
Normal file
|
@ -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:
|
133
platypush/plugins/mail/__init__.py
Normal file
133
platypush/plugins/mail/__init__.py
Normal file
|
@ -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:
|
341
platypush/plugins/mail/imap.py
Normal file
341
platypush/plugins/mail/imap.py
Normal file
|
@ -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 <https://tools.ietf.org/html/rfc3501>`_
|
||||
(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": "<SOMETHING@mail.gmail.com>",
|
||||
"in_reply_to": "<SOMETHING@mail.gmail.com>",
|
||||
"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:
|
|
@ -293,3 +293,5 @@ croniter
|
|||
# RPi.GPIO
|
||||
# RPLCD
|
||||
|
||||
# Support for IMAP mail integration
|
||||
# imapclient
|
||||
|
|
2
setup.py
2
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'],
|
||||
},
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue