[#348] Major refactor of the `mail` plugins and `mail` backend/plugin merge.
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Fabio Manganiello 2024-02-03 21:16:24 +01:00
parent d4fb35105b
commit 446b10d005
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
31 changed files with 2376 additions and 1229 deletions

View File

@ -11,7 +11,6 @@ Backends
platypush/backend/chat.telegram.rst
platypush/backend/gps.rst
platypush/backend/http.rst
platypush/backend/mail.rst
platypush/backend/midi.rst
platypush/backend/music.mopidy.rst
platypush/backend/music.mpd.rst

View File

@ -1,5 +0,0 @@
``mail``
==========================
.. automodule:: platypush.backend.mail
:members:

View File

@ -1,5 +0,0 @@
``mail.imap``
===============================
.. automodule:: platypush.plugins.mail.imap
:members:

View File

@ -0,0 +1,5 @@
``mail``
========
.. automodule:: platypush.plugins.mail
:members:

View File

@ -1,5 +0,0 @@
``mail.smtp``
===============================
.. automodule:: platypush.plugins.mail.smtp
:members:

View File

@ -62,8 +62,7 @@ Plugins
platypush/plugins/log.http.rst
platypush/plugins/logger.rst
platypush/plugins/luma.oled.rst
platypush/plugins/mail.imap.rst
platypush/plugins/mail.smtp.rst
platypush/plugins/mail.rst
platypush/plugins/mailgun.rst
platypush/plugins/mastodon.rst
platypush/plugins/matrix.rst

View File

@ -1,357 +0,0 @@
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 engine, create_engine, Column, Integer, String, DateTime
from sqlalchemy.orm import sessionmaker, scoped_session
from platypush.backend import Backend
from platypush.common.db import declarative_base
from platypush.config import Config
from platypush.context import get_plugin
from platypush.message.event.mail import (
MailReceivedEvent,
MailSeenEvent,
MailFlaggedEvent,
MailUnflaggedEvent,
)
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='[]')
flagged_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.
"""
def __init__(
self,
mailboxes: List[Dict[str, Any]],
timeout: Optional[int] = 60,
poll_seconds: 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 poll_seconds: How often the backend should check the mail (default: 60).
:param timeout: Connect/read timeout for a mailbox, in seconds (default: 60).
"""
self.logger.info('Initializing mail backend')
super().__init__(**kwargs)
self.poll_seconds = poll_seconds
self.mailboxes: List[Mailbox] = []
self.timeout = timeout
self._unread_msgs: List[Dict[int, Mail]] = [{}] * len(mailboxes)
self._flagged_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_load_mailboxes_status()
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_load_mailboxes_status(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, _ in enumerate(self.mailboxes):
if mbox_id not in records:
record = MailboxStatus(
mailbox_id=mbox_id,
unseen_message_ids='[]',
flagged_message_ids='[]',
)
session.add(record)
else:
record = records[mbox_id]
unseen_msg_ids = json.loads(record.unseen_message_ids or '[]')
flagged_msg_ids = json.loads(record.flagged_message_ids or '[]')
self._unread_msgs[mbox_id] = {msg_id: {} for msg_id in unseen_msg_ids}
self._flagged_msgs[mbox_id] = {msg_id: {} for msg_id in flagged_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(
unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args
):
def thread():
# noinspection PyUnresolvedReferences
unread = plugin.search_unseen_messages(**args).output
unread_queue.put({msg.id: msg for msg in unread})
# noinspection PyUnresolvedReferences
flagged = plugin.search_flagged_messages(**args).output
flagged_queue.put({msg.id: msg for msg in flagged})
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 _get_flagged_unflagged_msgs(
self, mailbox_idx: int, flagged_msgs: Dict[int, Mail]
) -> Tuple[Dict[int, Mail], Dict[int, Mail]]:
prev_flagged_msgs = self._flagged_msgs[mailbox_idx]
return {
msg_id: flagged_msgs[msg_id]
for msg_id in flagged_msgs
if msg_id not in prev_flagged_msgs
}, {
msg_id: prev_flagged_msgs[msg_id]
for msg_id in prev_flagged_msgs
if msg_id not in flagged_msgs
}
def _process_msg_events(
self,
mailbox_id: int,
unread: List[Mail],
seen: List[Mail],
flagged: List[Mail],
unflagged: 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, message=msg)
)
for msg in seen:
self.bus.post(
MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
)
for msg in flagged:
self.bus.post(
MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
)
for msg in unflagged:
self.bus.post(
MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
)
def _check_mailboxes(self) -> List[Tuple[Dict[int, Mail], Dict[int, Mail]]]:
workers = []
queues: List[Tuple[Queue, Queue]] = []
results = []
for mbox in self.mailboxes:
unread_queue, flagged_queue = [Queue()] * 2
worker = Thread(
target=self._check_thread(
unread_queue=unread_queue,
flagged_queue=flagged_queue,
plugin=mbox.plugin,
**mbox.args
)
)
worker.start()
workers.append(worker)
queues.append((unread_queue, flagged_queue))
for worker in workers:
worker.join(timeout=self.timeout)
for i, (unread_queue, flagged_queue) in enumerate(queues):
try:
unread = unread_queue.get(timeout=self.timeout)
flagged = flagged_queue.get(timeout=self.timeout)
results.append((unread, flagged))
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, flagged) in enumerate(results):
unread_msgs, seen_msgs = self._get_unread_seen_msgs(i, unread)
flagged_msgs, unflagged_msgs = self._get_flagged_unflagged_msgs(i, flagged)
self._process_msg_events(
i,
unread=list(unread_msgs.values()),
seen=list(seen_msgs.values()),
flagged=list(flagged_msgs.values()),
unflagged=list(unflagged_msgs.values()),
last_checked_date=mailbox_statuses[i].last_checked_date,
)
self._unread_msgs[i] = unread
self._flagged_msgs[i] = flagged
records.append(
MailboxStatus(
mailbox_id=i,
unseen_message_ids=json.dumps(list(unread.keys())),
flagged_message_ids=json.dumps(list(flagged.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:

View File

@ -1,10 +0,0 @@
manifest:
events:
platypush.message.event.mail.MailFlaggedEvent: when a message is marked as flagged/starred.
platypush.message.event.mail.MailReceivedEvent: when a new message is received.
platypush.message.event.mail.MailSeenEvent: when a message is marked as seen.
platypush.message.event.mail.MailUnflaggedEvent: when a message is marked as unflagged/unstarred.
install:
pip: []
package: platypush.backend.mail
type: backend

View File

@ -799,6 +799,73 @@ backend.http:
# proximity: 10.0
###
### -----------------------------------------
### Example configuration of the mail plugin.
### -----------------------------------------
#
# mail:
# # Display name to be used for outgoing emails. Default:
# # the `from` parameter will be used from :meth:`.send`,
# # and, if missing, the username from the account configuration
# # will be used.
# display_name: My Name
#
# # How often we should poll for updates (default: 60 seconds)
# poll_interval: 60
#
# # Connection timeout (default: 20 seconds)
# # Can be overridden on a per-account basis
# timeout: 20
#
# accounts:
# - name: "My Local Account"
# username: me@mydomain.com
# password: my-password
#
# # The default flag sets this account as the default one
# # for mail retrieval and sending if no account is
# # specified on an action. If multiple accounts are set
# # and none is set as default, and no account is specified
# # on an action, then the first configured account will be
# # used.
# default: true
# # Domain to be used for outgoing emails. Default: inferred
# # from the account configuration
# domain: example.com
#
#
# # Alternatively, you can run an external command
# # to get the password
# # password_cmd: "pass show mail/example.com"
#
# # Path to a custom certfile if the mail server uses a
# # self-signed certificate
# # certfile: /path/to/certfile
#
# # Path to a custom keyfile if the mail server requires
# # client authentication. It requires certfile to be set
# # too
# # keyfile: /path/to/keyfile
#
# incoming:
# # Supported protocols: imap, imaps
# server: imaps://mail.example.com:993
#
# outgoing:
# # The `incoming` and `outgoing` configurations can
# # override the global `username` and `password` and
# # other authentication parameters of the account
# username: me
# password: my-smtp-password
#
# # Supported protocols: smtp, smtps, smtp+starttls,
# server: smtps://mail.example.com:465
#
# # These folders will be monitored for new messages
# monitor_folders:
# - All Mail
###
### --------------------------------
### Some text-to-speech integrations
### --------------------------------

View File

@ -1,40 +1,39 @@
from typing import Optional
from platypush.message.event import Event
from platypush.plugins.mail import Mail
class MailEvent(Event):
def __init__(self, mailbox: str, message: Optional[Mail] = None, *args, **kwargs):
super().__init__(*args, mailbox=mailbox, message=message or {}, **kwargs)
class MailReceivedEvent(MailEvent):
"""
Triggered when a new email is received.
Base class for mail events.
"""
pass
def __init__(self, *args, account: str, folder: str, message, **kwargs):
super().__init__(
*args, account=account, folder=folder, message=message, **kwargs
)
class MailSeenEvent(MailEvent):
class UnseenMailEvent(MailEvent):
"""
Triggered when a new email is received or marked as unseen.
"""
class SeenMailEvent(MailEvent):
"""
Triggered when a previously unseen email is seen.
"""
pass
class MailFlaggedEvent(MailEvent):
class FlaggedMailEvent(MailEvent):
"""
Triggered when a message is marked as flagged/starred.
"""
pass
class MailUnflaggedEvent(MailEvent):
class UnflaggedMailEvent(MailEvent):
"""
Triggered when a message previously marked as flagged/starred is unflagged.
"""
pass
# vim:sw=4:ts=4:et:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,106 @@
from dataclasses import dataclass
from typing import Any, Collection, Dict, Optional
from ._model import AccountConfig
from ._plugin import BaseMailPlugin, MailInPlugin, MailOutPlugin
@dataclass
class Account:
"""
Models a mail account.
"""
name: str
poll_interval: float
display_name: Optional[str] = None
incoming: Optional[MailInPlugin] = None
outgoing: Optional[MailOutPlugin] = None
monitor_folders: Optional[Collection[str]] = None
default: bool = False
last_check: Optional[float] = None
@classmethod
def build(
cls,
name: str,
timeout: float,
poll_interval: float,
display_name: Optional[str] = None,
incoming: Optional[Dict[str, Any]] = None,
outgoing: Optional[Dict[str, Any]] = None,
monitor_folders: Optional[Collection[str]] = None,
username: Optional[str] = None,
password: Optional[str] = None,
password_cmd: Optional[str] = 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: bool = False,
last_check: Optional[float] = None,
) -> 'Account':
account_args = {
'username': username,
'password': password,
'password_cmd': password_cmd,
'access_token': access_token,
'oauth_mechanism': oauth_mechanism,
'oauth_vendor': oauth_vendor,
'display_name': display_name,
}
in_plugin = None
if incoming:
server = incoming.pop('server', None)
assert server, 'No server provided for incoming mail for account "{name}"'
keyfile = incoming.pop('keyfile', keyfile)
certfile = incoming.pop('certfile', certfile)
account = AccountConfig(**{**account_args, **incoming})
in_plugin = BaseMailPlugin.build(
server=server,
account=account,
timeout=timeout,
keyfile=keyfile,
certfile=certfile,
)
assert isinstance(
in_plugin, MailInPlugin
), 'Incoming mail plugin expected for account "{name}"'
out_plugin = None
if outgoing:
server = outgoing.pop('server', None)
assert server, 'No server provided for outgoing mail for account "{name}"'
keyfile = outgoing.pop('keyfile', keyfile)
certfile = outgoing.pop('certfile', certfile)
account = AccountConfig(**{**account_args, **outgoing})
out_plugin = BaseMailPlugin.build(
server=server,
account=account,
timeout=timeout,
keyfile=keyfile,
certfile=certfile,
)
assert isinstance(
out_plugin, MailOutPlugin
), 'Outgoing mail plugin expected for account "{name}"'
return cls(
name=name,
display_name=display_name,
incoming=in_plugin,
outgoing=out_plugin,
monitor_folders=monitor_folders,
poll_interval=poll_interval,
default=default,
last_check=last_check,
)
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,14 @@
from ._config import AccountConfig, ServerConfig
from ._mail import FolderStatus, Mail, MailFlagType, AccountsStatus
from ._transport import TransportEncryption
__all__ = [
'AccountConfig',
'FolderStatus',
'Mail',
'MailFlagType',
'AccountsStatus',
'ServerConfig',
'TransportEncryption',
]

View File

@ -0,0 +1,8 @@
from ._account import AccountConfig
from ._server import ServerConfig
__all__ = [
'AccountConfig',
'ServerConfig',
]

View File

@ -0,0 +1,43 @@
import subprocess
from dataclasses import dataclass
from typing import Optional
@dataclass
class AccountConfig:
"""
Model for a mail account configuration.
"""
username: str
password: Optional[str] = None
password_cmd: Optional[str] = None
access_token: Optional[str] = None
oauth_mechanism: Optional[str] = None
oauth_vendor: Optional[str] = None
display_name: Optional[str] = None
def __post_init__(self):
"""
Ensure that at least one of password, password_cmd or access_token is provided.
"""
assert (
self.password or self.password_cmd or self.access_token
), 'No password, password_cmd or access_token provided'
def get_password(self) -> str:
"""
Get the password either from a provided string or from a password command.
"""
if self.password_cmd:
with subprocess.Popen(
['sh', '-c', self.password_cmd], stdout=subprocess.PIPE
) as proc:
return proc.communicate()[0].decode()
assert self.password, 'No password provided'
return self.password
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,22 @@
from dataclasses import dataclass
from typing import Optional
from .._transport import TransportEncryption
@dataclass
class ServerConfig:
"""
Configuration for a mail server.
"""
server: str
port: int
encryption: TransportEncryption
timeout: float
keyfile: Optional[str] = None
certfile: Optional[str] = None
domain: Optional[str] = None
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,254 @@
import base64
import email
import logging
import json
from email.message import Message
from collections import defaultdict
from datetime import datetime
from enum import Enum
from typing import Any, Dict, IO, List, Optional, Union
from platypush.message import JSONAble
logger = logging.getLogger(__name__)
_text_types = {
'text/plain',
'text/html',
'text/rtf',
'text/enriched',
'text/markdown',
'application/rtf',
}
class Mail(JSONAble): # pylint: disable=too-few-public-methods
"""
Model for a mail message.
"""
def __init__(
self,
id: int, # pylint: disable=redefined-builtin
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 = '',
content: Optional[Any] = None,
**kwargs,
):
self.id = id
self.date = date
self.size = size
self.from_ = from_ or kwargs.pop('from', None)
self.to = to
self.cc = cc or []
self.bcc = bcc or []
self.subject = subject
self._content = content
self.args = kwargs
@staticmethod
def _parse_body(msg: Message) -> str:
body = ''
if msg.is_multipart():
for part in msg.walk():
if (
part.get_content_type() in _text_types
and
# skip any text/plain (txt) attachments
'attachment' not in part.get('Content-Disposition', '')
):
body = bytes(part.get_payload(decode=True)).decode()
break
else:
body = bytes(msg.get_payload(decode=True)).decode()
return body
@staticmethod
def _parse_attachments(msg: Message) -> List[dict]:
attachments = []
if msg.is_multipart():
for part in msg.walk():
if 'attachment' not in part.get('Content-Disposition', ''):
continue
raw_payload = bytes(part.get_payload(decode=True))
try:
# Try to decode it as a string
payload = raw_payload.decode()
except UnicodeDecodeError:
# Otherwise, return a JSON-encoded string
payload = base64.b64encode(raw_payload).decode()
attachments.append(
{
'filename': part.get_filename(),
'headers': dict(part),
'body': payload,
}
)
return attachments
@classmethod
def _parse_payload(cls, payload: Union[bytes, bytearray]) -> dict:
msg = email.message_from_bytes(payload)
return {
'headers': dict(msg),
'body': cls._parse_body(msg),
'attachments': cls._parse_attachments(msg),
}
@property
def content(self):
if isinstance(self._content, (bytes, bytearray)):
try:
return self._parse_payload(self._content)
except Exception as e:
logger.warning(
'Error while parsing payload for message ID %s: %s', self.id, e
)
logger.exception(e)
return self._content
def to_json(self) -> dict:
return {
'id': self.id,
'date': self.date,
'size': self.size,
'from': self.from_,
'to': self.to,
'cc': self.cc,
'bcc': self.bcc,
'subject': self.subject,
'content': self.content,
**self.args,
}
class MailFlagType(Enum):
"""
Types of supported mail flags.
"""
FLAGGED = 'flagged'
UNREAD = 'unread'
FolderStatus = Dict[MailFlagType, Dict[int, Mail]]
FoldersStatus = Dict[str, FolderStatus]
class AccountsStatus:
"""
Models the status of all the monitored mailboxes.
"""
def __init__(self):
self._dict: Dict[str, FoldersStatus] = defaultdict(
lambda: defaultdict(lambda: {evt: {} for evt in MailFlagType})
)
class Serializer(json.JSONEncoder):
def default(self, o):
if isinstance(o, datetime):
return o.isoformat()
if isinstance(o, MailFlagType):
return o.value
return json.JSONEncoder.default(self, o)
class Deserializer(json.JSONDecoder):
def __init__(self):
super().__init__(object_hook=self._hook)
@staticmethod
def _hook(o):
if 'date' in o:
o['date'] = datetime.fromisoformat(o['date'])
if 'flag' in o:
o['flag'] = MailFlagType(o['flag'])
return o
def __getitem__(self, item: str) -> FoldersStatus:
return self._dict[item]
def __setitem__(self, key: str, value: FoldersStatus):
self._dict[key] = value
def __delitem__(self, key: str) -> None:
del self._dict[key]
def __iter__(self):
return iter(self._dict)
def __len__(self) -> int:
return len(self._dict)
def __contains__(self, item) -> bool:
return item in self._dict
def __str__(self):
return json.dumps(self._dict, cls=self.Serializer)
def __repr__(self):
return self.__str__()
def items(self):
return self._dict.items()
def copy(self) -> 'AccountsStatus':
obj = AccountsStatus()
obj._dict.update(
{
account: {
folder: {MailFlagType(evt): msgs for evt, msgs in statuses.items()}
for folder, statuses in folders.items()
}
for account, folders in self._dict.items()
}
)
return obj
def get(self, key: str, default=None) -> Optional[FoldersStatus]:
return self._dict.get(key, default)
@classmethod
def read(cls, f: IO) -> 'AccountsStatus':
obj = cls()
obj._dict.update(
{
account: {
folder: {MailFlagType(evt): msgs for evt, msgs in statuses.items()}
for folder, statuses in folders.items()
}
for account, folders in json.load(f, cls=cls.Deserializer).items()
}
)
return obj
def write(self, f: IO):
f.write(
json.dumps(
{
account: {
folder: {
evt.value: {msg_id: {} for msg_id in msgs}
for evt, msgs in statuses.items()
}
for folder, statuses in folders.items()
}
for account, folders in self._dict.items()
},
cls=self.Serializer,
)
)
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,25 @@
from enum import IntEnum
class TransportEncryption(IntEnum):
"""
Enum for mail transport encryption types.
"""
NONE = 0
STARTTLS = 1
SSL = 2
@classmethod
def by_url_scheme(cls, scheme: str) -> 'TransportEncryption':
"""
Get the transport encryption type from the specified URL scheme.
"""
if scheme.endswith('+starttls'):
return cls.STARTTLS
if scheme.endswith('s'):
return cls.SSL
return cls.NONE
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,12 @@
from ._base import BaseMailPlugin
from ._in import MailInPlugin
from ._out import MailOutPlugin
from ._utils import mail_plugins
__all__ = [
'BaseMailPlugin',
'MailInPlugin',
'MailOutPlugin',
'mail_plugins',
]

View File

@ -0,0 +1,130 @@
import logging
import os
import re
from abc import ABC, abstractmethod
from typing import Dict, Optional
from urllib.parse import urlparse
from .._model import AccountConfig, ServerConfig, TransportEncryption
email_match_re = re.compile(r'[^@]+@[^@]+\.[^@]+')
class BaseMailPlugin(ABC): # pylint: disable=too-few-public-methods
"""
Base class for mail plugins.
"""
def __init__(
self,
server: str,
account: AccountConfig,
timeout: float,
keyfile: Optional[str] = None,
certfile: Optional[str] = None,
domain: Optional[str] = None,
**_,
):
self.logger = logging.getLogger(self.__class__.__name__)
self.account = account
self.server = self._get_server_config(
server=server,
timeout=timeout,
keyfile=keyfile,
certfile=certfile,
domain=domain,
)
@staticmethod
def _get_path(path: str) -> str:
return os.path.abspath(os.path.expanduser(path))
@classmethod
def _get_server_config(
cls,
server: str,
timeout: float,
keyfile: Optional[str],
certfile: Optional[str],
domain: Optional[str],
) -> ServerConfig:
url = urlparse(server)
assert url.hostname, f'No hostname specified: "{server}"'
ssl = TransportEncryption.by_url_scheme(url.scheme)
port = url.port or cls.default_ports().get(ssl)
assert port, f'No port specified and no default available: "{server}"'
if keyfile:
keyfile = cls._get_path(keyfile)
if certfile:
certfile = cls._get_path(certfile)
return ServerConfig(
server=url.hostname,
port=port,
encryption=ssl,
timeout=timeout,
keyfile=keyfile,
certfile=certfile,
domain=domain,
)
@property
def from_string(self):
"""
:return: The from string for the account.
"""
return self.account.display_name or self.account.username
@classmethod
@abstractmethod
def _matches_url_scheme(cls, scheme: str) -> bool:
raise NotImplementedError()
@classmethod
def can_handle(cls, url: str) -> bool:
"""
Check whether the plugin can handle the specified URL.
"""
return cls._matches_url_scheme(urlparse(url).scheme)
@classmethod
@abstractmethod
def default_ports(cls) -> Dict[TransportEncryption, int]:
"""
:return: A mapping of transport encryption to default port.
"""
raise NotImplementedError()
@classmethod
def build(
cls,
server: str,
account: AccountConfig,
timeout: float,
keyfile: Optional[str] = None,
certfile: Optional[str] = None,
) -> 'BaseMailPlugin':
from ._utils import mail_plugins
url_parsed = urlparse(server)
assert url_parsed.hostname, f'No hostname specified: "{server}"'
mail_cls = next(
(plugin for plugin in mail_plugins if plugin.can_handle(server)),
None,
)
assert mail_cls, f'No mail plugin found for URL: "{server}"'
return mail_cls(
server=server,
account=account,
timeout=timeout,
keyfile=keyfile,
certfile=certfile,
)
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,125 @@
from abc import ABC, abstractmethod
from typing import Dict, Iterable, List, Union
from .._model import Mail
from ._base import BaseMailPlugin
class MailInPlugin(BaseMailPlugin, ABC):
"""
Base class for mail in plugins.
"""
@abstractmethod
def get_folders(self, **_) -> list:
raise NotImplementedError()
@abstractmethod
def get_sub_folders(self, **_) -> list:
raise NotImplementedError()
@abstractmethod
def search(
self, criteria: Union[str, Iterable[str]], folder: str, **_
) -> List[Mail]:
raise NotImplementedError()
@abstractmethod
def search_unseen_messages(self, folder: str) -> List[Mail]:
raise NotImplementedError()
@abstractmethod
def search_flagged_messages(self, folder: str, **_) -> List[Mail]:
raise NotImplementedError()
@abstractmethod
def search_starred_messages(self, folder: str, **_) -> List[Mail]:
raise NotImplementedError()
@abstractmethod
def sort(
self,
folder: str,
sort_criteria: Union[str, Iterable[str]],
criteria: Union[str, Iterable[str]],
) -> list:
raise NotImplementedError()
@abstractmethod
def get_messages(self, *ids, with_body: bool = True, **_) -> Dict[int, Mail]:
raise NotImplementedError()
def get_message(
self, id, with_body: bool = True, **_ # pylint: disable=redefined-builtin
) -> Mail:
msgs = self.get_messages(id, with_body=with_body)
msg = msgs.get(id)
assert msg, f"Message {id} not found"
return msg
@abstractmethod
def create_folder(self, folder: str, **_):
raise NotImplementedError()
@abstractmethod
def rename_folder(self, old_name: str, new_name: str, **_):
raise NotImplementedError()
@abstractmethod
def delete_folder(self, folder: str, **_):
raise NotImplementedError()
@abstractmethod
def add_flags(
self, messages: list, flags: Union[str, Iterable[str]], folder: str, **_
):
raise NotImplementedError()
@abstractmethod
def set_flags(
self, messages: list, flags: Union[str, Iterable[str]], folder: str, **_
):
raise NotImplementedError()
@abstractmethod
def remove_flags(
self, messages: list, flags: Union[str, Iterable[str]], folder: str, **_
):
raise NotImplementedError()
@abstractmethod
def delete_messages(self, messages: list, folder: str, **_):
raise NotImplementedError()
@abstractmethod
def restore_messages(self, messages: list, folder: str, **_):
raise NotImplementedError()
@abstractmethod
def copy_messages(self, messages: list, dest_folder: str, source_folder: str, **_):
raise NotImplementedError()
@abstractmethod
def move_messages(self, messages: list, dest_folder: str, source_folder: str, **_):
raise NotImplementedError()
@abstractmethod
def expunge_messages(self, folder: str, messages: list, **_):
raise NotImplementedError()
@abstractmethod
def flag_messages(self, messages: list, folder: str, **_):
raise NotImplementedError()
@abstractmethod
def unflag_messages(self, messages: List[int], folder: str = 'INBOX', **_):
raise NotImplementedError()
def flag_message(self, message: int, folder: str, **_):
return self.flag_messages([message], folder=folder)
def unflag_message(self, message: int, folder: str, **_):
return self.unflag_messages([message], folder=folder)
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,138 @@
import os
from abc import ABC, abstractmethod
from datetime import datetime
from email.message import Message
from email.mime.application import MIMEApplication
from email.mime.audio import MIMEAudio
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from mimetypes import guess_type
from typing import Dict, Optional, Sequence, Union
from dateutil import tz
from .._utils import normalize_from_header
from ._base import BaseMailPlugin
class MailOutPlugin(BaseMailPlugin, ABC):
"""
Base class for mail out plugins.
"""
@abstractmethod
def send_message(self, message: Message, **_):
raise NotImplementedError()
@staticmethod
def _file_to_part(file: str) -> MIMEBase:
_type, _subtype, _type_class = 'application', 'octet-stream', MIMEApplication
mime_type, _sub_subtype = guess_type(file)
if mime_type:
_type, _subtype = mime_type.split('/')
if _sub_subtype:
_subtype += ';' + _sub_subtype
if _type == 'application':
_type_class = MIMEApplication
elif _type == 'audio':
_type_class = MIMEAudio
elif _type == 'image':
_type_class = MIMEImage
elif _type == 'text':
_type_class = MIMEText
args = {}
if _type_class != MIMEText:
mode = 'rb'
args['Name'] = os.path.basename(file)
else:
mode = 'r'
with open(file, mode) as f:
return _type_class(f.read(), _subtype, **args)
@classmethod
def create_message(
cls,
to: Union[str, Sequence[str]],
from_: Optional[str] = None,
cc: Optional[Union[str, Sequence[str]]] = None,
bcc: Optional[Union[str, Sequence[str]]] = None,
subject: str = '',
body: str = '',
body_type: str = 'plain',
attachments: Optional[Sequence[str]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Message:
assert from_, 'from/from_ field not specified'
content = MIMEText(body, body_type)
if attachments:
msg = MIMEMultipart()
msg.attach(content)
for attachment in attachments:
attachment = os.path.abspath(os.path.expanduser(attachment))
assert os.path.isfile(attachment), f'No such file: {attachment}'
part = cls._file_to_part(attachment)
part[
'Content-Disposition'
] = f'attachment; filename="{os.path.basename(attachment)}"'
msg.attach(part)
else:
msg = content
msg['From'] = from_
msg['To'] = to if isinstance(to, str) else ', '.join(to)
msg['Cc'] = ', '.join(cc) if cc else ''
msg['Bcc'] = ', '.join(bcc) if bcc else ''
msg['Subject'] = subject
msg['Date'] = (
datetime.now()
.replace(tzinfo=tz.tzlocal())
.strftime('%a, %d %b %Y %H:%M:%S %z')
)
if headers:
for name, value in headers.items():
msg.add_header(name, value)
return msg
def send(
self,
to: Union[str, Sequence[str]],
from_: Optional[str] = None,
cc: Optional[Union[str, Sequence[str]]] = None,
bcc: Optional[Union[str, Sequence[str]]] = None,
subject: str = '',
body: str = '',
body_type: str = 'plain',
attachments: Optional[Sequence[str]] = None,
headers: Optional[Dict[str, str]] = None,
**args,
):
if not from_ and 'from' in args:
from_ = args.pop('from')
msg = self.create_message(
to=to,
from_=normalize_from_header(from_, self.account, self.server),
cc=cc,
bcc=bcc,
subject=subject,
body=body,
body_type=body_type,
attachments=attachments,
headers=headers,
)
return self.send_message(msg, **args)
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,40 @@
import importlib
import inspect
import os
from threading import RLock
from typing import Set, Type
import pkgutil
from ._base import BaseMailPlugin
def _scan_mail_plugins() -> Set[Type[BaseMailPlugin]]:
from platypush.plugins import mail
# Recursively scan for class inside the `mail` module that inherit from
# BaseMailPlugin
base_file = inspect.getfile(mail)
plugins = set()
for _, name, _ in pkgutil.walk_packages(
[os.path.dirname(base_file)], prefix=f'{mail.__name__}.'
):
module = importlib.import_module(name)
for _, cls in inspect.getmembers(module, inspect.isclass):
if not inspect.isabstract(cls) and issubclass(cls, BaseMailPlugin):
plugins.add(cls)
return plugins
_mail_plugins_lock = RLock()
mail_plugins = set()
with _mail_plugins_lock:
if not mail_plugins:
mail_plugins = _scan_mail_plugins()
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,73 @@
import re
from typing import Optional
from urllib.parse import urlparse
from dns.exception import DNSException
from dns.resolver import resolve
from ._account import AccountConfig
from ._model import ServerConfig
_email_regex = re.compile(r'[^@]+@[^@]+\.[^@]+')
_from_header_regex = re.compile(r'^(?:"?([^"]+)"?\s+)?<?([^>]+)>?$')
_ipv4_regex = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
_ipv6_regex = re.compile(r'^[0-9a-fA-F:]+$')
def infer_mail_domain(account: AccountConfig, server: ServerConfig) -> str:
"""
Infers the mail domain from the account and server configuration.
"""
if server.domain:
return server.domain
if account.username and _email_regex.match(account.username):
return account.username.split('@', 1)[1]
if server.server:
if _ipv4_regex.match(server.server) or _ipv6_regex.match(server.server):
return server.server
host = urlparse(server.server).hostname
assert host, f'Could not parse hostname from server URL: {server.server}'
host_tokens = host.split('.')
while host_tokens:
try:
resolve(host, 'MX')
return host
except DNSException:
host_tokens.pop(0)
host = '.'.join(host_tokens)
raise AssertionError(f'Could not resolve MX record for {host}')
raise AssertionError('Could not infer mail domain from configuration.')
def infer_mail_address(account: AccountConfig, server: ServerConfig) -> str:
if account.username and _email_regex.match(account.username):
return account.username
return f'{account.username}@{infer_mail_domain(account, server)}'
def normalize_from_header(
from_: Optional[str], account: AccountConfig, server: ServerConfig
) -> str:
"""
Normalizes the value of the "From" header.
"""
if not from_:
from_ = account.display_name or account.username
if _email_regex.match(from_):
return from_
m = _from_header_regex.match(from_)
if m and _email_regex.match(m.group(2)):
return f'{m.group(1)} <{m.group(2)}>'
return f'{from_} <{infer_mail_address(account, server)}>'

View File

@ -1,11 +1,13 @@
import email
from typing import Optional, List, Dict, Union, Any, Tuple
import ssl
from contextlib import contextmanager
from typing import Generator, Iterable, 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
from .._model import Mail, TransportEncryption
from .._plugin import MailInPlugin
class MailImapPlugin(MailInPlugin):
@ -13,114 +15,47 @@ class MailImapPlugin(MailInPlugin):
Plugin to interact with a mail server over IMAP.
"""
_default_port = 143
_default_ssl_port = 993
@classmethod
def _matches_url_scheme(cls, scheme: str) -> bool:
return scheme in ('imap', 'imaps')
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,
)
@classmethod
def default_ports(cls) -> Dict[TransportEncryption, int]:
return {
TransportEncryption.NONE: 143,
TransportEncryption.SSL: 993,
}
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))
@contextmanager
def connect(self) -> Generator[IMAPClient, None, None]:
has_ssl = self.server.encryption == TransportEncryption.SSL
context = None
if info.ssl:
import ssl
if has_ssl and self.server.certfile:
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,
context.load_cert_chain(
certfile=self.server.certfile, keyfile=self.server.keyfile
)
return client
client = IMAPClient(
host=self.server.server,
port=self.server.port,
ssl=has_ssl,
ssl_context=context,
)
if self.account.access_token:
client.oauth2_login(
self.account.username,
access_token=self.account.access_token,
mech=self.account.oauth_mechanism or 'XOAUTH2',
vendor=self.account.oauth_vendor,
)
else:
pwd = self.account.get_password()
client.login(self.account.username, pwd)
yield client
client.logout()
@staticmethod
def _get_folders(data: List[tuple]) -> List[Dict[str, str]]:
@ -137,90 +72,12 @@ class MailImapPlugin(MailInPlugin):
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]:
def _parse_address(imap_addr: Address) -> Dict[str, Optional[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()
),
'email': imap_addr.mailbox.decode() + '@' + imap_addr.host.decode(),
}
@classmethod
@ -264,456 +121,194 @@ class MailImapPlugin(MailInPlugin):
message['sender'] = cls._parse_addresses(envelope.sender)
message['subject'] = envelope.subject.decode() if envelope.subject else None
message['to'] = cls._parse_addresses(envelope.to)
if b'BODY[]' in imap_msg:
message['content'] = imap_msg[b'BODY[]']
return Mail(**message)
@action
@staticmethod
def _convert_flags(flags: Union[str, Iterable[str]]) -> List[bytes]:
if isinstance(flags, str):
flags = [flag.strip() for flag in flags.split(',')]
return [('\\' + flag).encode() for flag in flags]
def get_folders(
self, folder: str = '', pattern: str = '*', **_
) -> List[Dict[str, str]]:
with self.connect() as client:
data = client.list_folders(directory=folder, pattern=pattern)
return self._get_folders(data)
def get_sub_folders(
self, folder: str = '', pattern: str = '*', **_
) -> List[Dict[str, str]]:
with self.connect() as client:
data = client.list_sub_folders(directory=folder, pattern=pattern)
return self._get_folders(data)
def search(
self,
criteria: Union[str, List[str]] = 'ALL',
criteria: Union[str, Iterable[str]] = 'ALL',
folder: str = 'INBOX',
attributes: Optional[List[str]] = None,
**connect_args
attributes: Optional[Iterable[str]] = None,
**_,
) -> 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']
attributes = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE']
else:
attributes = [attr.upper() for attr in attributes]
data = {}
with self.connect(**connect_args) as client:
with self.connect() as client:
client.select_folder(folder, readonly=True)
ids = client.search(criteria)
if len(ids):
ids = client.search(criteria) # type: ignore
if ids:
data = client.fetch(list(ids), attributes)
return [
self._parse_message(msg_id, data[msg_id]) for msg_id in sorted(data.keys())
]
@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
)
def search_unseen_messages(self, folder: str = 'INBOX') -> List[Mail]:
return self.search(criteria='UNSEEN', directory=folder)
@action
def search_flagged_messages(
self, folder: str = 'INBOX', **connect_args
) -> List[Mail]:
"""
Shortcut for :meth:`.search` that returns only the flagged/starred messages.
"""
return self.search(
criteria='Flagged', directory=folder, attributes=['ALL'], **connect_args
)
def search_flagged_messages(self, folder: str = 'INBOX', **_) -> List[Mail]:
return self.search(criteria='Flagged', directory=folder)
@action
def search_starred_messages(
self, folder: str = 'INBOX', **connect_args
) -> List[Mail]:
"""
Shortcut for :meth:`.search` that returns only the flagged/starred messages.
"""
return self.search_flagged_messages(folder, **connect_args)
def search_starred_messages(self, folder: str = 'INBOX', **_) -> List[Mail]:
return self.search_flagged_messages(folder)
@action
def sort(
self,
folder: str = 'INBOX',
sort_criteria: Union[str, List[str]] = 'ARRIVAL',
criteria: Union[str, List[str]] = 'ALL',
**connect_args
sort_criteria: Union[str, Iterable[str]] = 'ARRIVAL',
criteria: Union[str, Iterable[str]] = 'ALL',
) -> List[int]:
"""
Return a list of message ids from the currently selected folder, sorted by ``sort_criteria`` and optionally
filtered by ``criteria``. Note that SORT is an extension to the IMAP4 standard so it may not be supported by
all IMAP servers.
:param folder: Folder to be searched (default: ``INBOX``).
:param sort_criteria: It may be a sequence of strings or a single string. IMAPClient will take care any required
conversions. Valid *sort_criteria* values::
.. code-block:: python
['ARRIVAL']
['SUBJECT', 'ARRIVAL']
'ARRIVAL'
'REVERSE SIZE'
:param criteria: Optional filter for the messages, as specified in :meth:`.search`.
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
:return: A list of message IDs that fit the criteria.
"""
with self.connect(**connect_args) as client:
with self.connect() as client:
client.select_folder(folder, readonly=True)
msg_ids = client.sort(sort_criteria=sort_criteria, criteria=criteria)
msg_ids = client.sort(sort_criteria=sort_criteria, criteria=criteria) # type: ignore
return msg_ids
@action
def get_message(self, message: int, folder: str = 'INBOX', **connect_args) -> Mail:
"""
Get the full content of a message given the ID returned by :meth:`.search`.
:param message: 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:
def get_messages(
self,
*ids: int,
folder: str = 'INBOX',
with_body: bool = True,
**_,
) -> Dict[int, Mail]:
ret = {}
with self.connect() as client:
client.select_folder(folder, readonly=True)
data = client.fetch(message, ['ALL', 'RFC822'])
assert message in data, 'No such message ID: {}'.format(message)
attrs = ['FLAGS', 'RFC822.SIZE', 'INTERNALDATE', 'ENVELOPE']
if with_body:
attrs.append('BODY[]')
data = data[message]
ret = self._parse_message(message, data)
msg = email.message_from_bytes(data[b'RFC822'])
ret.payload = msg.get_payload()
data = client.fetch(ids, attrs)
for id in ids: # pylint: disable=redefined-builtin
msg = data.get(id)
if not msg:
continue
ret[id] = self._parse_message(id, msg)
return ret
@action
def create_folder(self, folder: str, **connect_args):
"""
Create a folder on the server.
:param folder: Folder name.
:param connect_args:
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
with self.connect(**connect_args) as client:
def create_folder(self, folder: str, **_):
with self.connect() as client:
client.create_folder(folder)
@action
def rename_folder(self, old_name: str, new_name: str, **connect_args):
"""
Rename a folder on the server.
:param old_name: Previous name
:param new_name: New name
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
with self.connect(**connect_args) as client:
def rename_folder(self, old_name: str, new_name: str, **_):
with self.connect() as client:
client.rename_folder(old_name, new_name)
@action
def delete_folder(self, folder: str, **connect_args):
"""
Delete a folder from the server.
:param folder: Folder name.
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
with self.connect(**connect_args) as client:
def delete_folder(self, folder: str, **_):
with self.connect() as client:
client.delete_folder(folder)
@staticmethod
def _convert_flags(flags: Union[str, List[str]]) -> List[bytes]:
if isinstance(flags, str):
flags = [flag.strip() for flag in flags.split(',')]
return [('\\' + flag).encode() for flag in flags]
@action
def add_flags(
self,
messages: List[int],
flags: Union[str, List[str]],
flags: Union[str, Iterable[str]],
folder: str = 'INBOX',
**connect_args
**_,
):
"""
Add a set of flags to the specified set of message IDs.
:param messages: List of message IDs.
:param flags: List of flags to be added. Examples:
.. code-block:: python
['Flagged']
['Seen', 'Deleted']
['Junk']
:param folder: IMAP folder (default: ``INBOX``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
with self.connect(**connect_args) as client:
with self.connect() as client:
client.select_folder(folder)
client.add_flags(messages, self._convert_flags(flags))
@action
def set_flags(
self,
messages: List[int],
flags: Union[str, List[str]],
flags: Union[str, Iterable[str]],
folder: str = 'INBOX',
**connect_args
**_,
):
"""
Set a set of flags to the specified set of message IDs.
:param messages: List of message IDs.
:param flags: List of flags to be added. Examples:
.. code-block:: python
['Flagged']
['Seen', 'Deleted']
['Junk']
:param folder: IMAP folder (default: ``INBOX``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
with self.connect(**connect_args) as client:
with self.connect() as client:
client.select_folder(folder)
client.set_flags(messages, self._convert_flags(flags))
@action
def remove_flags(
self,
messages: List[int],
flags: Union[str, List[str]],
flags: Union[str, Iterable[str]],
folder: str = 'INBOX',
**connect_args
**_,
):
"""
Remove a set of flags to the specified set of message IDs.
:param messages: List of message IDs.
:param flags: List of flags to be added. Examples:
.. code-block:: python
['Flagged']
['Seen', 'Deleted']
['Junk']
:param folder: IMAP folder (default: ``INBOX``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
with self.connect(**connect_args) as client:
with self.connect() as client:
client.select_folder(folder)
client.remove_flags(messages, self._convert_flags(flags))
@action
def flag_messages(self, messages: List[int], folder: str = 'INBOX', **connect_args):
"""
Add a flag/star to the specified set of message IDs.
def flag_messages(self, messages: List[int], folder: str = 'INBOX', **_):
return self.add_flags(messages, ['Flagged'], folder=folder)
:param messages: List of message IDs.
:param folder: IMAP folder (default: ``INBOX``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
return self.add_flags(messages, ['Flagged'], folder=folder, **connect_args)
def unflag_messages(self, messages: List[int], folder: str = 'INBOX', **_):
return self.remove_flags(messages, ['Flagged'], folder=folder)
@action
def unflag_messages(
self, messages: List[int], folder: str = 'INBOX', **connect_args
):
"""
Remove a flag/star from the specified set of message IDs.
:param messages: List of message IDs.
:param folder: IMAP folder (default: ``INBOX``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
return self.remove_flags(messages, ['Flagged'], folder=folder, **connect_args)
@action
def flag_message(self, message: int, folder: str = 'INBOX', **connect_args):
"""
Add a flag/star to the specified set of message ID.
:param message: Message ID.
:param folder: IMAP folder (default: ``INBOX``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
return self.flag_messages([message], folder=folder, **connect_args)
@action
def unflag_message(self, message: int, folder: str = 'INBOX', **connect_args):
"""
Remove a flag/star from the specified set of message ID.
:param message: Message ID.
:param folder: IMAP folder (default: ``INBOX``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
return self.unflag_messages([message], folder=folder, **connect_args)
@action
def delete_messages(
self,
messages: List[int],
folder: str = 'INBOX',
expunge: bool = True,
**connect_args
**_,
):
"""
Set a specified set of message IDs as deleted.
:param messages: List of message IDs.
:param folder: IMAP folder (default: ``INBOX``).
:param expunge: If set then the messages will also be expunged from the folder, otherwise they will only be
marked as deleted (default: ``True``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
self.add_flags(messages, ['Deleted'], folder=folder, **connect_args)
self.add_flags(messages, ['Deleted'], folder=folder)
if expunge:
self.expunge_messages(folder=folder, messages=messages, **connect_args)
self.expunge_messages(folder=folder, messages=messages)
@action
def undelete_messages(
self, messages: List[int], folder: str = 'INBOX', **connect_args
):
"""
Remove the ``Deleted`` flag from the specified set of message IDs.
def restore_messages(self, messages: List[int], folder: str = 'INBOX', **_):
return self.remove_flags(messages, ['Deleted'], folder=folder)
:param messages: List of message IDs.
:param folder: IMAP folder (default: ``INBOX``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
return self.remove_flags(messages, ['Deleted'], folder=folder, **connect_args)
@action
def copy_messages(
self,
messages: List[int],
dest_folder: str,
source_folder: str = 'INBOX',
**connect_args
**_,
):
"""
Copy a set of messages IDs from a folder to another.
:param messages: List of message IDs.
:param source_folder: Source folder.
:param dest_folder: Destination folder.
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
with self.connect(**connect_args) as client:
with self.connect() as client:
client.select_folder(source_folder)
client.copy(messages, dest_folder)
@action
def move_messages(
self,
messages: List[int],
dest_folder: str,
source_folder: str = 'INBOX',
**connect_args
**_,
):
"""
Move a set of messages IDs from a folder to another.
:param messages: List of message IDs.
:param source_folder: Source folder.
:param dest_folder: Destination folder.
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
with self.connect(**connect_args) as client:
with self.connect() as client:
client.select_folder(source_folder)
client.move(messages, dest_folder)
@action
def expunge_messages(
self,
folder: str = 'INBOX',
messages: Optional[List[int]] = None,
**connect_args
**_,
):
"""
When ``messages`` is not set, remove all the messages from ``folder`` marked as ``Deleted``.
:param folder: IMAP folder (default: ``INBOX``).
:param messages: List of message IDs to expunge (default: all those marked as ``Deleted``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
"""
with self.connect(**connect_args) as client:
with self.connect() as client:
client.select_folder(folder)
client.expunge(messages)

View File

@ -1,7 +0,0 @@
manifest:
events: {}
install:
pip:
- imapclient
package: platypush.plugins.mail.imap
type: plugin

View File

@ -0,0 +1,20 @@
manifest:
events:
- platypush.message.event.mail.FlaggedMailEvent
- platypush.message.event.mail.SeenMailEvent
- platypush.message.event.mail.UnflaggedMailEvent
- platypush.message.event.mail.UnseenMailEvent
install:
apk:
- py3-dnspython
apt:
- python3-dnspython
dnf:
- python-dnspython
pacman:
- python-dnspython
pip:
- dnspython
- imapclient
package: platypush.plugins.mail
type: plugin

View File

@ -1,9 +1,11 @@
from contextlib import contextmanager
from email.message import Message
from typing import Optional, List
from typing import Dict, Generator
from smtplib import SMTP, SMTP_SSL
from platypush.plugins.mail import MailOutPlugin, ServerInfo
from .._model import TransportEncryption
from .._plugin import MailOutPlugin
class MailSmtpPlugin(MailOutPlugin):
@ -11,80 +13,63 @@ class MailSmtpPlugin(MailOutPlugin):
Plugin to interact with a mail server over SMTP.
"""
_default_port = 25
_default_ssl_port = 465
@classmethod
def _matches_url_scheme(cls, scheme: str) -> bool:
return scheme in ('smtp', 'smtps')
def __init__(self, server: Optional[str] = None, port: Optional[int] = None, local_hostname: Optional[str] = None,
source_address: Optional[List[str]] = 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: 25 for plain, 465 for SSL).
:param local_hostname: If specified, local_hostname is used as the FQDN of the local host in the HELO/EHLO
command. Otherwise, the local hostname is found using socket.getfqdn().
:param source_address: The optional source_address parameter allows binding to some specific source address in
a machine with multiple network interfaces, and/or to some specific source TCP port. It takes a 2-tuple
(host, port), for the socket to bind to as its source address before connecting. If omitted (or if host or
port are '' and/or 0 respectively) the OS default behavior will be used.
:param username: SMTP username.
:param password: SMTP 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.local_hostname = local_hostname
self.source_address = source_address
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,
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,
timeout=timeout)
def connect(self, **connect_args) -> SMTP:
info = self._get_server_info(**connect_args)
self.logger.info('Connecting to {}'.format(info.server))
smtp_args = {
'host': info.server,
'port': info.port,
'local_hostname': self.local_hostname,
'source_address': self.source_address,
@classmethod
def default_ports(cls) -> Dict[TransportEncryption, int]:
return {
TransportEncryption.NONE: 25,
TransportEncryption.SSL: 465,
TransportEncryption.STARTTLS: 587,
}
if info.ssl:
@contextmanager
def connect(self) -> Generator[SMTP, None, None]:
smtp_args = {
'host': self.server.server,
'port': self.server.port,
}
if self.server.encryption == TransportEncryption.SSL:
client_type = SMTP_SSL
smtp_args.update(certfile=info.certfile, keyfile=info.keyfile)
if self.server.certfile:
smtp_args.update(certfile=self.server.certfile)
if self.server.keyfile:
smtp_args.update(keyfile=self.server.keyfile)
else:
client_type = SMTP
client = client_type(**smtp_args)
if info.password:
client.login(info.username, info.password)
return client
if self.server.encryption == TransportEncryption.STARTTLS:
client.ehlo()
client.starttls()
else:
client.ehlo_or_helo_if_needed()
def send_message(self, message: Message, **connect_args):
with self.connect(**connect_args) as client:
errors = client.sendmail(message['From'], message['To'], message.as_string())
pwd = None
try:
pwd = self.account.get_password()
except AssertionError:
pass
if errors:
return None, ['{}: {}'.format(code, err) for code, err in errors.items()]
if pwd:
client.login(self.account.username, pwd)
yield client
client.quit()
def send_message(self, message: Message, **_):
with self.connect() as client:
errors = client.sendmail(
message['From'], message['To'], message.as_string()
)
assert not errors, 'Failed to send message: ' + str(
[f'{code}: {err}' for code, err in errors.items()]
)
# vim:sw=4:ts=4:et:

View File

@ -1,6 +0,0 @@
manifest:
events: {}
install:
pip: []
package: platypush.plugins.mail.smtp
type: plugin

View File

@ -1,40 +1,55 @@
import os
from typing import Optional, Union, Sequence
import requests
from requests.auth import HTTPBasicAuth
from platypush.plugins import action
from platypush.plugins.mail import MailOutPlugin
from platypush.plugins import Plugin, action
class MailgunPlugin(MailOutPlugin):
class MailgunPlugin(Plugin):
"""
Mailgun integration.
"""
def __init__(self, api_key: str, api_base_url: str = 'https://api.mailgun.net/v3', **kwargs):
def __init__(
self,
api_key: str,
api_base_url: str = 'https://api.mailgun.net/v3',
domain: Optional[str] = None,
timeout: float = 20.0,
**kwargs,
):
"""
:param api_key: Mailgun API secret key.
:param api_base_url: Use ``https://api.eu.mailgun.net/v3`` if you are using an EU account.
:param domain: Default registered domain that should be used for sending
emails if not specified in the :meth:`.send` action.
:param timeout: Default timeout for the requests (default: 20 seconds).
"""
super().__init__(**kwargs)
self._api_key = api_key
self._api_base_url = api_base_url
def send_message(self, *_, **__):
pass
self._domain = domain
self._timeout = timeout
@action
def send(
self, domain: str, to: Union[str, Sequence[str]], from_: Optional[str] = None,
cc: Optional[Union[str, Sequence[str]]] = None, bcc: Optional[Union[str, Sequence[str]]] = None,
subject: str = '', body: str = '', body_type: str = 'plain', attachments: Optional[Sequence[str]] = None,
**kwargs
):
self,
to: Union[str, Sequence[str]],
from_: Optional[str] = None,
cc: Optional[Union[str, Sequence[str]]] = None,
bcc: Optional[Union[str, Sequence[str]]] = None,
subject: str = '',
body: str = '',
body_type: str = 'plain',
domain: Optional[str] = None,
**kwargs,
):
"""
Send an email through Mailgun.
:param domain: From which registered domain the email(s) should be sent.
:param domain: From which registered domain the email(s) should be sent
(default: the domain specified in the plugin configuration).
:param to: Receiver(s), as comma-separated strings or list.
:param from_: Sender email address (``from`` is also supported outside of Python contexts).
:param cc: Carbon-copy addresses, as comma-separated strings or list
@ -42,21 +57,28 @@ class MailgunPlugin(MailOutPlugin):
:param subject: Mail subject.
:param body: Mail body.
:param body_type: Mail body type - ``text`` or ``html``.
:param attachments: List of attachment files to send.
"""
domain = domain or self._domain
assert domain, 'No domain specified'
from_ = from_ or kwargs.pop('from', None)
rs = requests.post(
f'{self._api_base_url}/{domain}/messages',
timeout=self._timeout,
auth=HTTPBasicAuth('api', self._api_key),
data={
'to': ', '.join([to] if isinstance(to, str) else to),
'subject': subject,
**{'html' if body_type == 'html' else 'text': body},
**({'from': from_} if from_ else {}),
**({'cc': ', '.join([cc] if isinstance(cc, str) else cc)} if cc else {}),
**({'bcc': ', '.join([bcc] if isinstance(bcc, str) else bcc)} if bcc else {}),
**(
{'cc': ', '.join([cc] if isinstance(cc, str) else cc)} if cc else {}
),
**(
{'bcc': ', '.join([bcc] if isinstance(bcc, str) else bcc)}
if bcc
else {}
),
},
files=[os.path.expanduser(attachment) for attachment in (attachments or [])]
)
rs.raise_for_status()

View File

@ -270,8 +270,8 @@ setup(
],
# Support for LCD display integration
'lcd': ['RPi.GPIO', 'RPLCD'],
# Support for IMAP mail integration
'imap': ['imapclient'],
# Support for email integration
'mail': ['imapclient', 'dnspython'],
# Support for NextCloud integration
'nextcloud': ['nextcloud-api-wrapper'],
# Support for VLC integration