forked from platypush/platypush
Closes: #348 Reviewed-on: platypush/platypush#362
This commit is contained in:
parent
5badc935ac
commit
c8944feca4
31 changed files with 2376 additions and 1229 deletions
|
@ -11,7 +11,6 @@ Backends
|
||||||
platypush/backend/chat.telegram.rst
|
platypush/backend/chat.telegram.rst
|
||||||
platypush/backend/gps.rst
|
platypush/backend/gps.rst
|
||||||
platypush/backend/http.rst
|
platypush/backend/http.rst
|
||||||
platypush/backend/mail.rst
|
|
||||||
platypush/backend/midi.rst
|
platypush/backend/midi.rst
|
||||||
platypush/backend/music.mopidy.rst
|
platypush/backend/music.mopidy.rst
|
||||||
platypush/backend/music.mpd.rst
|
platypush/backend/music.mpd.rst
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
``mail``
|
|
||||||
==========================
|
|
||||||
|
|
||||||
.. automodule:: platypush.backend.mail
|
|
||||||
:members:
|
|
|
@ -1,5 +0,0 @@
|
||||||
``mail.imap``
|
|
||||||
===============================
|
|
||||||
|
|
||||||
.. automodule:: platypush.plugins.mail.imap
|
|
||||||
:members:
|
|
5
docs/source/platypush/plugins/mail.rst
Normal file
5
docs/source/platypush/plugins/mail.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
``mail``
|
||||||
|
========
|
||||||
|
|
||||||
|
.. automodule:: platypush.plugins.mail
|
||||||
|
:members:
|
|
@ -1,5 +0,0 @@
|
||||||
``mail.smtp``
|
|
||||||
===============================
|
|
||||||
|
|
||||||
.. automodule:: platypush.plugins.mail.smtp
|
|
||||||
:members:
|
|
|
@ -62,8 +62,7 @@ Plugins
|
||||||
platypush/plugins/log.http.rst
|
platypush/plugins/log.http.rst
|
||||||
platypush/plugins/logger.rst
|
platypush/plugins/logger.rst
|
||||||
platypush/plugins/luma.oled.rst
|
platypush/plugins/luma.oled.rst
|
||||||
platypush/plugins/mail.imap.rst
|
platypush/plugins/mail.rst
|
||||||
platypush/plugins/mail.smtp.rst
|
|
||||||
platypush/plugins/mailgun.rst
|
platypush/plugins/mailgun.rst
|
||||||
platypush/plugins/mastodon.rst
|
platypush/plugins/mastodon.rst
|
||||||
platypush/plugins/matrix.rst
|
platypush/plugins/matrix.rst
|
||||||
|
|
|
@ -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:
|
|
|
@ -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
|
|
|
@ -799,6 +799,73 @@ backend.http:
|
||||||
# proximity: 10.0
|
# 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
|
### Some text-to-speech integrations
|
||||||
### --------------------------------
|
### --------------------------------
|
||||||
|
|
|
@ -1,40 +1,39 @@
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from platypush.message.event import Event
|
from platypush.message.event import Event
|
||||||
from platypush.plugins.mail import Mail
|
|
||||||
|
|
||||||
|
|
||||||
class MailEvent(Event):
|
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.
|
Triggered when a previously unseen email is seen.
|
||||||
"""
|
"""
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class MailFlaggedEvent(MailEvent):
|
class FlaggedMailEvent(MailEvent):
|
||||||
"""
|
"""
|
||||||
Triggered when a message is marked as flagged/starred.
|
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.
|
Triggered when a message previously marked as flagged/starred is unflagged.
|
||||||
"""
|
"""
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
File diff suppressed because it is too large
Load diff
106
platypush/plugins/mail/_account.py
Normal file
106
platypush/plugins/mail/_account.py
Normal 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:
|
14
platypush/plugins/mail/_model/__init__.py
Normal file
14
platypush/plugins/mail/_model/__init__.py
Normal 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',
|
||||||
|
]
|
8
platypush/plugins/mail/_model/_config/__init__.py
Normal file
8
platypush/plugins/mail/_model/_config/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from ._account import AccountConfig
|
||||||
|
from ._server import ServerConfig
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'AccountConfig',
|
||||||
|
'ServerConfig',
|
||||||
|
]
|
43
platypush/plugins/mail/_model/_config/_account.py
Normal file
43
platypush/plugins/mail/_model/_config/_account.py
Normal 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:
|
22
platypush/plugins/mail/_model/_config/_server.py
Normal file
22
platypush/plugins/mail/_model/_config/_server.py
Normal 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:
|
254
platypush/plugins/mail/_model/_mail.py
Normal file
254
platypush/plugins/mail/_model/_mail.py
Normal 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:
|
25
platypush/plugins/mail/_model/_transport.py
Normal file
25
platypush/plugins/mail/_model/_transport.py
Normal 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:
|
12
platypush/plugins/mail/_plugin/__init__.py
Normal file
12
platypush/plugins/mail/_plugin/__init__.py
Normal 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',
|
||||||
|
]
|
130
platypush/plugins/mail/_plugin/_base.py
Normal file
130
platypush/plugins/mail/_plugin/_base.py
Normal 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:
|
125
platypush/plugins/mail/_plugin/_in.py
Normal file
125
platypush/plugins/mail/_plugin/_in.py
Normal 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:
|
138
platypush/plugins/mail/_plugin/_out.py
Normal file
138
platypush/plugins/mail/_plugin/_out.py
Normal 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:
|
40
platypush/plugins/mail/_plugin/_utils.py
Normal file
40
platypush/plugins/mail/_plugin/_utils.py
Normal 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:
|
73
platypush/plugins/mail/_utils.py
Normal file
73
platypush/plugins/mail/_utils.py
Normal 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)}>'
|
|
@ -1,11 +1,13 @@
|
||||||
import email
|
import ssl
|
||||||
from typing import Optional, List, Dict, Union, Any, Tuple
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Generator, Iterable, Optional, List, Dict, Union, Any, Tuple
|
||||||
|
|
||||||
from imapclient import IMAPClient
|
from imapclient import IMAPClient
|
||||||
from imapclient.response_types import Address
|
from imapclient.response_types import Address
|
||||||
|
|
||||||
from platypush.plugins import action
|
from .._model import Mail, TransportEncryption
|
||||||
from platypush.plugins.mail import MailInPlugin, ServerInfo, Mail
|
from .._plugin import MailInPlugin
|
||||||
|
|
||||||
|
|
||||||
class MailImapPlugin(MailInPlugin):
|
class MailImapPlugin(MailInPlugin):
|
||||||
|
@ -13,114 +15,47 @@ class MailImapPlugin(MailInPlugin):
|
||||||
Plugin to interact with a mail server over IMAP.
|
Plugin to interact with a mail server over IMAP.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_default_port = 143
|
@classmethod
|
||||||
_default_ssl_port = 993
|
def _matches_url_scheme(cls, scheme: str) -> bool:
|
||||||
|
return scheme in ('imap', 'imaps')
|
||||||
|
|
||||||
def __init__(
|
@classmethod
|
||||||
self,
|
def default_ports(cls) -> Dict[TransportEncryption, int]:
|
||||||
server: str,
|
return {
|
||||||
port: Optional[int] = None,
|
TransportEncryption.NONE: 143,
|
||||||
username: Optional[str] = None,
|
TransportEncryption.SSL: 993,
|
||||||
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(
|
@contextmanager
|
||||||
self,
|
def connect(self) -> Generator[IMAPClient, None, None]:
|
||||||
server: Optional[str] = None,
|
has_ssl = self.server.encryption == TransportEncryption.SSL
|
||||||
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
|
context = None
|
||||||
|
if has_ssl and self.server.certfile:
|
||||||
if info.ssl:
|
|
||||||
import ssl
|
|
||||||
|
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
context.load_cert_chain(certfile=info.certfile, keyfile=info.keyfile)
|
context.load_cert_chain(
|
||||||
|
certfile=self.server.certfile, keyfile=self.server.keyfile
|
||||||
|
)
|
||||||
|
|
||||||
client = IMAPClient(
|
client = IMAPClient(
|
||||||
host=info.server, port=info.port, ssl=info.ssl, ssl_context=context
|
host=self.server.server,
|
||||||
)
|
port=self.server.port,
|
||||||
if info.password:
|
ssl=has_ssl,
|
||||||
client.login(info.username, info.password)
|
ssl_context=context,
|
||||||
elif info.access_token:
|
|
||||||
client.oauth2_login(
|
|
||||||
info.username,
|
|
||||||
access_token=info.access_token,
|
|
||||||
mech=info.oauth_mechanism,
|
|
||||||
vendor=info.oauth_vendor,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return client
|
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
|
@staticmethod
|
||||||
def _get_folders(data: List[tuple]) -> List[Dict[str, str]]:
|
def _get_folders(data: List[tuple]) -> List[Dict[str, str]]:
|
||||||
|
@ -137,90 +72,12 @@ class MailImapPlugin(MailInPlugin):
|
||||||
|
|
||||||
return folders
|
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
|
@staticmethod
|
||||||
def _parse_address(imap_addr: Address) -> Dict[str, str]:
|
def _parse_address(imap_addr: Address) -> Dict[str, Optional[str]]:
|
||||||
return {
|
return {
|
||||||
'name': imap_addr.name.decode() if imap_addr.name else None,
|
'name': imap_addr.name.decode() if imap_addr.name else None,
|
||||||
'route': imap_addr.route.decode() if imap_addr.route else None,
|
'route': imap_addr.route.decode() if imap_addr.route else None,
|
||||||
'email': '{name}@{host}'.format(
|
'email': imap_addr.mailbox.decode() + '@' + imap_addr.host.decode(),
|
||||||
name=imap_addr.mailbox.decode(), host=imap_addr.host.decode()
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -264,456 +121,194 @@ class MailImapPlugin(MailInPlugin):
|
||||||
message['sender'] = cls._parse_addresses(envelope.sender)
|
message['sender'] = cls._parse_addresses(envelope.sender)
|
||||||
message['subject'] = envelope.subject.decode() if envelope.subject else None
|
message['subject'] = envelope.subject.decode() if envelope.subject else None
|
||||||
message['to'] = cls._parse_addresses(envelope.to)
|
message['to'] = cls._parse_addresses(envelope.to)
|
||||||
|
if b'BODY[]' in imap_msg:
|
||||||
|
message['content'] = imap_msg[b'BODY[]']
|
||||||
|
|
||||||
return Mail(**message)
|
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(
|
def search(
|
||||||
self,
|
self,
|
||||||
criteria: Union[str, List[str]] = 'ALL',
|
criteria: Union[str, Iterable[str]] = 'ALL',
|
||||||
folder: str = 'INBOX',
|
folder: str = 'INBOX',
|
||||||
attributes: Optional[List[str]] = None,
|
attributes: Optional[Iterable[str]] = None,
|
||||||
**connect_args
|
**_,
|
||||||
) -> List[Mail]:
|
) -> 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:
|
if not attributes:
|
||||||
attributes = ['ALL']
|
attributes = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE']
|
||||||
else:
|
else:
|
||||||
attributes = [attr.upper() for attr in attributes]
|
attributes = [attr.upper() for attr in attributes]
|
||||||
|
|
||||||
data = {}
|
data = {}
|
||||||
with self.connect(**connect_args) as client:
|
with self.connect() as client:
|
||||||
client.select_folder(folder, readonly=True)
|
client.select_folder(folder, readonly=True)
|
||||||
ids = client.search(criteria)
|
ids = client.search(criteria) # type: ignore
|
||||||
if len(ids):
|
if ids:
|
||||||
data = client.fetch(list(ids), attributes)
|
data = client.fetch(list(ids), attributes)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
self._parse_message(msg_id, data[msg_id]) for msg_id in sorted(data.keys())
|
self._parse_message(msg_id, data[msg_id]) for msg_id in sorted(data.keys())
|
||||||
]
|
]
|
||||||
|
|
||||||
@action
|
def search_unseen_messages(self, folder: str = 'INBOX') -> List[Mail]:
|
||||||
def search_unseen_messages(
|
return self.search(criteria='UNSEEN', directory=folder)
|
||||||
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 search_flagged_messages(self, folder: str = 'INBOX', **_) -> List[Mail]:
|
||||||
def search_flagged_messages(
|
return self.search(criteria='Flagged', directory=folder)
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
@action
|
def search_starred_messages(self, folder: str = 'INBOX', **_) -> List[Mail]:
|
||||||
def search_starred_messages(
|
return self.search_flagged_messages(folder)
|
||||||
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)
|
|
||||||
|
|
||||||
@action
|
|
||||||
def sort(
|
def sort(
|
||||||
self,
|
self,
|
||||||
folder: str = 'INBOX',
|
folder: str = 'INBOX',
|
||||||
sort_criteria: Union[str, List[str]] = 'ARRIVAL',
|
sort_criteria: Union[str, Iterable[str]] = 'ARRIVAL',
|
||||||
criteria: Union[str, List[str]] = 'ALL',
|
criteria: Union[str, Iterable[str]] = 'ALL',
|
||||||
**connect_args
|
|
||||||
) -> List[int]:
|
) -> List[int]:
|
||||||
"""
|
with self.connect() as client:
|
||||||
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:
|
|
||||||
client.select_folder(folder, readonly=True)
|
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
|
return msg_ids
|
||||||
|
|
||||||
@action
|
def get_messages(
|
||||||
def get_message(self, message: int, folder: str = 'INBOX', **connect_args) -> Mail:
|
self,
|
||||||
"""
|
*ids: int,
|
||||||
Get the full content of a message given the ID returned by :meth:`.search`.
|
folder: str = 'INBOX',
|
||||||
|
with_body: bool = True,
|
||||||
:param message: Message ID.
|
**_,
|
||||||
:param folder: Folder name (default: ``INBOX``).
|
) -> Dict[int, Mail]:
|
||||||
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
|
ret = {}
|
||||||
:return: A message in the same format as :meth:`.search`, with an added ``payload`` attribute containing the
|
with self.connect() as client:
|
||||||
body/payload.
|
|
||||||
"""
|
|
||||||
with self.connect(**connect_args) as client:
|
|
||||||
client.select_folder(folder, readonly=True)
|
client.select_folder(folder, readonly=True)
|
||||||
data = client.fetch(message, ['ALL', 'RFC822'])
|
attrs = ['FLAGS', 'RFC822.SIZE', 'INTERNALDATE', 'ENVELOPE']
|
||||||
assert message in data, 'No such message ID: {}'.format(message)
|
if with_body:
|
||||||
|
attrs.append('BODY[]')
|
||||||
|
|
||||||
data = data[message]
|
data = client.fetch(ids, attrs)
|
||||||
ret = self._parse_message(message, data)
|
for id in ids: # pylint: disable=redefined-builtin
|
||||||
msg = email.message_from_bytes(data[b'RFC822'])
|
msg = data.get(id)
|
||||||
ret.payload = msg.get_payload()
|
if not msg:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ret[id] = self._parse_message(id, msg)
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@action
|
def create_folder(self, folder: str, **_):
|
||||||
def create_folder(self, folder: str, **connect_args):
|
with self.connect() as client:
|
||||||
"""
|
|
||||||
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:
|
|
||||||
client.create_folder(folder)
|
client.create_folder(folder)
|
||||||
|
|
||||||
@action
|
def rename_folder(self, old_name: str, new_name: str, **_):
|
||||||
def rename_folder(self, old_name: str, new_name: str, **connect_args):
|
with self.connect() as client:
|
||||||
"""
|
|
||||||
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:
|
|
||||||
client.rename_folder(old_name, new_name)
|
client.rename_folder(old_name, new_name)
|
||||||
|
|
||||||
@action
|
def delete_folder(self, folder: str, **_):
|
||||||
def delete_folder(self, folder: str, **connect_args):
|
with self.connect() as client:
|
||||||
"""
|
|
||||||
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:
|
|
||||||
client.delete_folder(folder)
|
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(
|
def add_flags(
|
||||||
self,
|
self,
|
||||||
messages: List[int],
|
messages: List[int],
|
||||||
flags: Union[str, List[str]],
|
flags: Union[str, Iterable[str]],
|
||||||
folder: str = 'INBOX',
|
folder: str = 'INBOX',
|
||||||
**connect_args
|
**_,
|
||||||
):
|
):
|
||||||
"""
|
with self.connect() as client:
|
||||||
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:
|
|
||||||
client.select_folder(folder)
|
client.select_folder(folder)
|
||||||
client.add_flags(messages, self._convert_flags(flags))
|
client.add_flags(messages, self._convert_flags(flags))
|
||||||
|
|
||||||
@action
|
|
||||||
def set_flags(
|
def set_flags(
|
||||||
self,
|
self,
|
||||||
messages: List[int],
|
messages: List[int],
|
||||||
flags: Union[str, List[str]],
|
flags: Union[str, Iterable[str]],
|
||||||
folder: str = 'INBOX',
|
folder: str = 'INBOX',
|
||||||
**connect_args
|
**_,
|
||||||
):
|
):
|
||||||
"""
|
with self.connect() as client:
|
||||||
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:
|
|
||||||
client.select_folder(folder)
|
client.select_folder(folder)
|
||||||
client.set_flags(messages, self._convert_flags(flags))
|
client.set_flags(messages, self._convert_flags(flags))
|
||||||
|
|
||||||
@action
|
|
||||||
def remove_flags(
|
def remove_flags(
|
||||||
self,
|
self,
|
||||||
messages: List[int],
|
messages: List[int],
|
||||||
flags: Union[str, List[str]],
|
flags: Union[str, Iterable[str]],
|
||||||
folder: str = 'INBOX',
|
folder: str = 'INBOX',
|
||||||
**connect_args
|
**_,
|
||||||
):
|
):
|
||||||
"""
|
with self.connect() as client:
|
||||||
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:
|
|
||||||
client.select_folder(folder)
|
client.select_folder(folder)
|
||||||
client.remove_flags(messages, self._convert_flags(flags))
|
client.remove_flags(messages, self._convert_flags(flags))
|
||||||
|
|
||||||
@action
|
def flag_messages(self, messages: List[int], folder: str = 'INBOX', **_):
|
||||||
def flag_messages(self, messages: List[int], folder: str = 'INBOX', **connect_args):
|
return self.add_flags(messages, ['Flagged'], folder=folder)
|
||||||
"""
|
|
||||||
Add a flag/star to the specified set of message IDs.
|
|
||||||
|
|
||||||
:param messages: List of message IDs.
|
def unflag_messages(self, messages: List[int], folder: str = 'INBOX', **_):
|
||||||
:param folder: IMAP folder (default: ``INBOX``).
|
return self.remove_flags(messages, ['Flagged'], folder=folder)
|
||||||
: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)
|
|
||||||
|
|
||||||
@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(
|
def delete_messages(
|
||||||
self,
|
self,
|
||||||
messages: List[int],
|
messages: List[int],
|
||||||
folder: str = 'INBOX',
|
folder: str = 'INBOX',
|
||||||
expunge: bool = True,
|
expunge: bool = True,
|
||||||
**connect_args
|
**_,
|
||||||
):
|
):
|
||||||
"""
|
self.add_flags(messages, ['Deleted'], folder=folder)
|
||||||
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)
|
|
||||||
if expunge:
|
if expunge:
|
||||||
self.expunge_messages(folder=folder, messages=messages, **connect_args)
|
self.expunge_messages(folder=folder, messages=messages)
|
||||||
|
|
||||||
@action
|
def restore_messages(self, messages: List[int], folder: str = 'INBOX', **_):
|
||||||
def undelete_messages(
|
return self.remove_flags(messages, ['Deleted'], folder=folder)
|
||||||
self, messages: List[int], folder: str = 'INBOX', **connect_args
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Remove the ``Deleted`` flag 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, ['Deleted'], folder=folder, **connect_args)
|
|
||||||
|
|
||||||
@action
|
|
||||||
def copy_messages(
|
def copy_messages(
|
||||||
self,
|
self,
|
||||||
messages: List[int],
|
messages: List[int],
|
||||||
dest_folder: str,
|
dest_folder: str,
|
||||||
source_folder: str = 'INBOX',
|
source_folder: str = 'INBOX',
|
||||||
**connect_args
|
**_,
|
||||||
):
|
):
|
||||||
"""
|
with self.connect() as client:
|
||||||
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:
|
|
||||||
client.select_folder(source_folder)
|
client.select_folder(source_folder)
|
||||||
client.copy(messages, dest_folder)
|
client.copy(messages, dest_folder)
|
||||||
|
|
||||||
@action
|
|
||||||
def move_messages(
|
def move_messages(
|
||||||
self,
|
self,
|
||||||
messages: List[int],
|
messages: List[int],
|
||||||
dest_folder: str,
|
dest_folder: str,
|
||||||
source_folder: str = 'INBOX',
|
source_folder: str = 'INBOX',
|
||||||
**connect_args
|
**_,
|
||||||
):
|
):
|
||||||
"""
|
with self.connect() as client:
|
||||||
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:
|
|
||||||
client.select_folder(source_folder)
|
client.select_folder(source_folder)
|
||||||
client.move(messages, dest_folder)
|
client.move(messages, dest_folder)
|
||||||
|
|
||||||
@action
|
|
||||||
def expunge_messages(
|
def expunge_messages(
|
||||||
self,
|
self,
|
||||||
folder: str = 'INBOX',
|
folder: str = 'INBOX',
|
||||||
messages: Optional[List[int]] = None,
|
messages: Optional[List[int]] = None,
|
||||||
**connect_args
|
**_,
|
||||||
):
|
):
|
||||||
"""
|
with self.connect() as client:
|
||||||
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:
|
|
||||||
client.select_folder(folder)
|
client.select_folder(folder)
|
||||||
client.expunge(messages)
|
client.expunge(messages)
|
||||||
|
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
manifest:
|
|
||||||
events: {}
|
|
||||||
install:
|
|
||||||
pip:
|
|
||||||
- imapclient
|
|
||||||
package: platypush.plugins.mail.imap
|
|
||||||
type: plugin
|
|
20
platypush/plugins/mail/manifest.yaml
Normal file
20
platypush/plugins/mail/manifest.yaml
Normal 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
|
|
@ -1,9 +1,11 @@
|
||||||
|
from contextlib import contextmanager
|
||||||
from email.message import Message
|
from email.message import Message
|
||||||
from typing import Optional, List
|
from typing import Dict, Generator
|
||||||
|
|
||||||
from smtplib import SMTP, SMTP_SSL
|
from smtplib import SMTP, SMTP_SSL
|
||||||
|
|
||||||
from platypush.plugins.mail import MailOutPlugin, ServerInfo
|
from .._model import TransportEncryption
|
||||||
|
from .._plugin import MailOutPlugin
|
||||||
|
|
||||||
|
|
||||||
class MailSmtpPlugin(MailOutPlugin):
|
class MailSmtpPlugin(MailOutPlugin):
|
||||||
|
@ -11,80 +13,63 @@ class MailSmtpPlugin(MailOutPlugin):
|
||||||
Plugin to interact with a mail server over SMTP.
|
Plugin to interact with a mail server over SMTP.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_default_port = 25
|
@classmethod
|
||||||
_default_ssl_port = 465
|
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,
|
@classmethod
|
||||||
source_address: Optional[List[str]] = None, username: Optional[str] = None,
|
def default_ports(cls) -> Dict[TransportEncryption, int]:
|
||||||
password: Optional[str] = None, password_cmd: Optional[str] = None, access_token: Optional[str] = None,
|
return {
|
||||||
oauth_mechanism: Optional[str] = 'XOAUTH2', oauth_vendor: Optional[str] = None, ssl: bool = False,
|
TransportEncryption.NONE: 25,
|
||||||
keyfile: Optional[str] = None, certfile: Optional[str] = None, timeout: Optional[int] = 60, **kwargs):
|
TransportEncryption.SSL: 465,
|
||||||
"""
|
TransportEncryption.STARTTLS: 587,
|
||||||
: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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
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:
|
else:
|
||||||
client_type = SMTP
|
client_type = SMTP
|
||||||
|
|
||||||
client = client_type(**smtp_args)
|
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):
|
pwd = None
|
||||||
with self.connect(**connect_args) as client:
|
try:
|
||||||
errors = client.sendmail(message['From'], message['To'], message.as_string())
|
pwd = self.account.get_password()
|
||||||
|
except AssertionError:
|
||||||
|
pass
|
||||||
|
|
||||||
if errors:
|
if pwd:
|
||||||
return None, ['{}: {}'.format(code, err) for code, err in errors.items()]
|
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:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
manifest:
|
|
||||||
events: {}
|
|
||||||
install:
|
|
||||||
pip: []
|
|
||||||
package: platypush.plugins.mail.smtp
|
|
||||||
type: plugin
|
|
|
@ -1,40 +1,55 @@
|
||||||
import os
|
|
||||||
from typing import Optional, Union, Sequence
|
from typing import Optional, Union, Sequence
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.auth import HTTPBasicAuth
|
from requests.auth import HTTPBasicAuth
|
||||||
|
|
||||||
from platypush.plugins import action
|
from platypush.plugins import Plugin, action
|
||||||
from platypush.plugins.mail import MailOutPlugin
|
|
||||||
|
|
||||||
|
|
||||||
class MailgunPlugin(MailOutPlugin):
|
class MailgunPlugin(Plugin):
|
||||||
"""
|
"""
|
||||||
Mailgun integration.
|
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_key: Mailgun API secret key.
|
||||||
:param api_base_url: Use ``https://api.eu.mailgun.net/v3`` if you are using an EU account.
|
: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)
|
super().__init__(**kwargs)
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._api_base_url = api_base_url
|
self._api_base_url = api_base_url
|
||||||
|
self._domain = domain
|
||||||
def send_message(self, *_, **__):
|
self._timeout = timeout
|
||||||
pass
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def send(
|
def send(
|
||||||
self, domain: str, to: Union[str, Sequence[str]], from_: Optional[str] = None,
|
self,
|
||||||
cc: Optional[Union[str, Sequence[str]]] = None, bcc: Optional[Union[str, Sequence[str]]] = None,
|
to: Union[str, Sequence[str]],
|
||||||
subject: str = '', body: str = '', body_type: str = 'plain', attachments: Optional[Sequence[str]] = None,
|
from_: Optional[str] = None,
|
||||||
**kwargs
|
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.
|
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 to: Receiver(s), as comma-separated strings or list.
|
||||||
:param from_: Sender email address (``from`` is also supported outside of Python contexts).
|
:param from_: Sender email address (``from`` is also supported outside of Python contexts).
|
||||||
:param cc: Carbon-copy addresses, as comma-separated strings or list
|
:param cc: Carbon-copy addresses, as comma-separated strings or list
|
||||||
|
@ -42,21 +57,28 @@ class MailgunPlugin(MailOutPlugin):
|
||||||
:param subject: Mail subject.
|
:param subject: Mail subject.
|
||||||
:param body: Mail body.
|
:param body: Mail body.
|
||||||
:param body_type: Mail body type - ``text`` or ``html``.
|
: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)
|
from_ = from_ or kwargs.pop('from', None)
|
||||||
rs = requests.post(
|
rs = requests.post(
|
||||||
f'{self._api_base_url}/{domain}/messages',
|
f'{self._api_base_url}/{domain}/messages',
|
||||||
|
timeout=self._timeout,
|
||||||
auth=HTTPBasicAuth('api', self._api_key),
|
auth=HTTPBasicAuth('api', self._api_key),
|
||||||
data={
|
data={
|
||||||
'to': ', '.join([to] if isinstance(to, str) else to),
|
'to': ', '.join([to] if isinstance(to, str) else to),
|
||||||
'subject': subject,
|
'subject': subject,
|
||||||
**{'html' if body_type == 'html' else 'text': body},
|
**{'html' if body_type == 'html' else 'text': body},
|
||||||
**({'from': from_} if from_ else {}),
|
**({'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()
|
rs.raise_for_status()
|
||||||
|
|
4
setup.py
4
setup.py
|
@ -270,8 +270,8 @@ setup(
|
||||||
],
|
],
|
||||||
# Support for LCD display integration
|
# Support for LCD display integration
|
||||||
'lcd': ['RPi.GPIO', 'RPLCD'],
|
'lcd': ['RPi.GPIO', 'RPLCD'],
|
||||||
# Support for IMAP mail integration
|
# Support for email integration
|
||||||
'imap': ['imapclient'],
|
'mail': ['imapclient', 'dnspython'],
|
||||||
# Support for NextCloud integration
|
# Support for NextCloud integration
|
||||||
'nextcloud': ['nextcloud-api-wrapper'],
|
'nextcloud': ['nextcloud-api-wrapper'],
|
||||||
# Support for VLC integration
|
# Support for VLC integration
|
||||||
|
|
Loading…
Reference in a new issue