Added IMAP plugin and generic mail check backend [links to #146]

This commit is contained in:
Fabio Manganiello 2020-08-31 15:32:30 +02:00
parent f1ab923bfe
commit 1681f80728
8 changed files with 778 additions and 0 deletions

View File

@ -262,6 +262,7 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers',
'Adafruit_Python_DHT',
'RPi.GPIO',
'RPLCD',
'imapclient',
]
sys.path.insert(0, os.path.abspath('../..'))

259
platypush/backend/mail.py Normal file
View File

@ -0,0 +1,259 @@
import json
import os
import pathlib
from dataclasses import dataclass
from datetime import datetime
from queue import Queue, Empty
from threading import Thread, RLock
from typing import List, Dict, Any, Optional, Tuple
from sqlalchemy import create_engine, Column, Integer, String, DateTime
import sqlalchemy.engine as engine
from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base
from platypush.backend import Backend
from platypush.config import Config
from platypush.context import get_plugin
from platypush.message.event.mail import MailReceivedEvent, MailSeenEvent
from platypush.plugins.mail import MailInPlugin, Mail
# <editor-fold desc="Database tables">
Base = declarative_base()
Session = scoped_session(sessionmaker())
class MailboxStatus(Base):
""" Models the MailboxStatus table, containing information about the state of a monitored mailbox. """
__tablename__ = 'MailboxStatus'
mailbox_id = Column(Integer, primary_key=True)
unseen_message_ids = Column(String, default='[]')
last_checked_date = Column(DateTime)
# </editor-fold>
# <editor-fold desc="Mailbox model">
@dataclass
class Mailbox:
plugin: MailInPlugin
name: str
args: dict
# </editor-fold>
class MailBackend(Backend):
"""
This backend can subscribe to one or multiple mail servers and trigger events when new messages are received or
messages are marked as seen.
It requires at least one plugin that extends :class:`platypush.plugins.mail.MailInPlugin` (e.g. ``mail.imap``) to
be installed.
Triggers:
- :class:`platypush.message.event.mail.MailReceivedEvent` when a new message is received.
- :class:`platypush.message.event.mail.MailSeenEvent` when a message is marked as seen.
"""
def __init__(self, mailboxes: List[Dict[str, Any]], timeout: Optional[int] = 60, **kwargs):
"""
:param mailboxes: List of mailboxes to be monitored. Each mailbox entry contains a ``plugin`` attribute to
identify the :class:`platypush.plugins.mail.MailInPlugin` plugin that will be used (e.g. ``mail.imap``)
and the arguments that will be passed to :meth:`platypush.plugins.mail.MailInPlugin.search_unseen_messages`.
The ``name`` parameter can be used to identify this mailbox in the relevant events, otherwise
``Mailbox #{id}`` will be used as a name. Example configuration:
.. code-block:: yaml
backend.mail:
mailboxes:
- plugin: mail.imap
name: "My Local Server"
username: me@mydomain.com
password: my-imap-password
server: localhost
ssl: true
folder: "All Mail"
- plugin: mail.imap
name: "GMail"
username: me@gmail.com
password: my-google-password
server: imap.gmail.com
ssl: true
folder: "INBOX"
If you have a default configuration available for a mail plugin you can implicitly reuse it without
replicating it here. Example:
.. code-block:: yaml
mail.imap:
username: me@mydomain.com
password: my-imap-password
server: localhost
ssl: true
backend.mail:
mailboxes:
# The mail.imap default configuration will be used
- plugin: mail.imap
name: "My Local Server"
folder: "All Mail"
:param timeout: Connect/read timeout for a mailbox, in seconds (default: 60).
"""
self.logger.info('Initializing mail backend')
super().__init__(**kwargs)
self.mailboxes: List[Mailbox] = []
self.timeout = timeout
self._unread_msgs: List[Dict[int, Mail]] = [{}] * len(mailboxes)
self._db_lock = RLock()
self.workdir = os.path.join(os.path.expanduser(Config.get('workdir')), 'mail')
self.dbfile = os.path.join(self.workdir, 'backend.db')
# Parse mailboxes
for i, mbox in enumerate(mailboxes):
assert 'plugin' in mbox, 'No plugin attribute specified for mailbox n.{}'.format(i)
plugin = get_plugin(mbox.pop('plugin'))
assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format(plugin)
name = mbox.pop('name') if 'name' in mbox else 'Mailbox #{}'.format(i + 1)
self.mailboxes.append(Mailbox(plugin=plugin, name=name, args=mbox))
# Configure/sync db
pathlib.Path(self.workdir).mkdir(parents=True, exist_ok=True, mode=0o750)
self._db = self._db_get_engine()
Base.metadata.create_all(self._db)
Session.configure(bind=self._db)
self._db_sync_mailboxes()
self.logger.info('Mail backend initialized')
# <editor-fold desc="Database methods">
def _db_get_engine(self) -> engine.Engine:
return create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
def _db_sync_mailboxes(self) -> None:
mailbox_ids = list(range(len(self.mailboxes)))
with self._db_lock:
session = Session()
records = {
record.mailbox_id: record
for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all()
}
for mbox_id, mbox in enumerate(self.mailboxes):
if mbox_id not in records:
record = MailboxStatus(mailbox_id=mbox_id, unseen_message_ids='[]')
session.add(record)
else:
record = records[mbox_id]
unseen_msg_ids = json.loads(record.unseen_message_ids or '[]')
self._unread_msgs[mbox_id] = {msg_id: {} for msg_id in unseen_msg_ids}
session.commit()
def _db_get_mailbox_status(self, mailbox_ids: List[int]) -> Dict[int, MailboxStatus]:
with self._db_lock:
session = Session()
return {
record.mailbox_id: record
for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all()
}
# </editor-fold>
# <editor-fold desc="Parse unread messages logic">
@staticmethod
def _check_thread(q: Queue, plugin: MailInPlugin, **args):
def thread():
# noinspection PyUnresolvedReferences
unread = plugin.search_unseen_messages(**args).output
q.put({msg.id: msg for msg in unread})
return thread
def _get_unread_seen_msgs(self, mailbox_idx: int, unread_msgs: Dict[int, Mail]) \
-> Tuple[Dict[int, Mail], Dict[int, Mail]]:
prev_unread_msgs = self._unread_msgs[mailbox_idx]
return {
msg_id: unread_msgs[msg_id]
for msg_id in unread_msgs
if msg_id not in prev_unread_msgs
}, {
msg_id: prev_unread_msgs[msg_id]
for msg_id in prev_unread_msgs
if msg_id not in unread_msgs
}
def _process_msg_events(self, mailbox_id: int, unread: List[Mail], seen: List[Mail],
last_checked_date: Optional[datetime] = None):
for msg in unread:
if msg.date and last_checked_date and msg.date < last_checked_date:
continue
self.bus.post(MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, msg=msg))
for msg in seen:
self.bus.post(MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, msg=msg))
def _check_mailboxes(self) -> List[Dict[int, Mail]]:
workers = []
queues = []
results = []
for mbox in self.mailboxes:
q = Queue()
worker = Thread(target=self._check_thread(q, plugin=mbox.plugin, **mbox.args))
worker.start()
workers.append(worker)
queues.append(q)
for worker in workers:
worker.join(timeout=self.timeout)
for i, q in enumerate(queues):
try:
unread = q.get(timeout=self.timeout)
results.append(unread)
except Empty:
self.logger.warning('Checks on mailbox #{} timed out after {} seconds'.format(i + 1, self.timeout))
continue
return results
# </editor-fold>
# <editor-fold desc="Loop function">
def loop(self):
records = []
mailbox_statuses = self._db_get_mailbox_status(list(range(len(self.mailboxes))))
results = self._check_mailboxes()
for i, unread in enumerate(results):
unread_msgs, seen_msgs = self._get_unread_seen_msgs(i, unread)
self._process_msg_events(i, unread=list(unread_msgs.values()), seen=list(seen_msgs.values()),
last_checked_date=mailbox_statuses[i].last_checked_date)
self._unread_msgs[i] = unread
records.append(MailboxStatus(mailbox_id=i,
unseen_message_ids=json.dumps([msg_id for msg_id in unread.keys()]),
last_checked_date=datetime.now()))
with self._db_lock:
session = Session()
for record in records:
session.merge(record)
session.commit()
# </editor-fold>
# vim:sw=4:ts=4:et:

View File

@ -1,12 +1,24 @@
from abc import ABC, abstractmethod
import datetime
import logging
import inspect
import json
import time
from typing import Union
logger = logging.getLogger(__name__)
class JSONAble(ABC):
"""
Generic interface for JSON-able objects.
"""
@abstractmethod
def to_json(self) -> Union[str, list, dict]:
raise NotImplementedError()
class Message(object):
""" Message generic class """
@ -48,6 +60,9 @@ class Message(object):
if value is not None:
return value
if isinstance(obj, JSONAble):
return obj.to_json()
return super().default(obj)
def __init__(self, timestamp=None, *args, **kwargs):

View File

@ -0,0 +1,25 @@
from typing import Optional, Dict
from platypush.message.event import Event
class MailEvent(Event):
def __init__(self, mailbox: str, message: Optional[Dict] = None, *args, **kwargs):
super().__init__(*args, mailbox=mailbox, message=message or {}, **kwargs)
class MailReceivedEvent(MailEvent):
"""
Triggered when a new email is received.
"""
pass
class MailSeenEvent(MailEvent):
"""
Triggered when a previously unseen email is seen.
"""
pass
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,133 @@
import inspect
import os
import subprocess
from abc import ABC
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, List, Union, Any, Dict
from platypush.message import JSONAble
from platypush.plugins import Plugin, action
@dataclass
class ServerInfo:
server: str
port: int
username: Optional[str]
password: Optional[str]
ssl: bool
keyfile: Optional[str]
certfile: Optional[str]
access_token: Optional[str]
oauth_mechanism: Optional[str]
oauth_vendor: Optional[str]
timeout: Optional[int]
class Mail(JSONAble):
def __init__(self, id: int, date: datetime, size: int,
from_: Optional[Union[Dict[str, str], List[str]]] = None,
to: Optional[Union[Dict[str, str], List[str]]] = None,
cc: Optional[Union[Dict[str, str], List[str]]] = None,
bcc: Optional[Union[Dict[str, str], List[str]]] = None, subject: str = '',
payload: Optional[Any] = None, **kwargs):
self.id = id
self.date = date
self.size = size
self.from_ = from_ or kwargs.get('from')
self.to = to
self.cc = cc or []
self.bcc = bcc or []
self.subject = subject
self.payload = payload
for k, v in kwargs.items():
setattr(self, k, v)
def to_json(self) -> dict:
return {
k if k != 'from_' else 'from': v
for k, v in dict(inspect.getmembers(self)).items()
if not k.startswith('_') and not callable(v)
}
class MailPlugin(Plugin, ABC):
"""
Base class for mail plugins.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.server_info: Optional[ServerInfo] = None
@staticmethod
def _get_password(password: Optional[str] = None, password_cmd: Optional[str] = None) -> Optional[str]:
"""
Get the password either from a provided string or from a password command.
"""
if not password_cmd:
return password
proc = subprocess.Popen(['sh', '-c', password_cmd], stdout=subprocess.PIPE)
password = proc.communicate()[0].decode()
return password or None
@staticmethod
def _get_path(path: str) -> str:
return os.path.abspath(os.path.expanduser(path))
def _get_server_info(self, server: Optional[str] = None, port: Optional[int] = None, username: Optional[str] = None,
password: Optional[str] = None, password_cmd: Optional[str] = None,
ssl: Optional[bool] = None, keyfile: Optional[str] = None, certfile: Optional[str] = None,
access_token: Optional[str] = None, oauth_mechanism: Optional[str] = None,
oauth_vendor: Optional[str] = None, default_port: Optional[int] = None,
default_ssl_port: Optional[int] = None, timeout: Optional[int] = None, **kwargs) \
-> ServerInfo:
if not port:
port = default_ssl_port if ssl else default_port
info = ServerInfo(server=server, port=port, username=username,
password=self._get_password(password, password_cmd), ssl=ssl, keyfile=keyfile,
certfile=certfile, access_token=access_token, oauth_mechanism=oauth_mechanism,
oauth_vendor=oauth_vendor, timeout=timeout)
if server:
return info
if self.server_info:
assert self.server_info.server, 'No server specified'
return self.server_info
return info
class MailInPlugin(MailPlugin, ABC):
"""
Base class for mail in plugins.
"""
@action
def get_folders(self) -> list:
raise NotImplementedError()
@action
def get_sub_folders(self) -> list:
raise NotImplementedError()
@action
def search(self, criteria: str, directory: Optional[str] = None) -> list:
raise NotImplementedError()
@action
def search_unseen_messages(self, directory: Optional[str] = None) -> list:
raise NotImplementedError()
@action
def get_message(self, id) -> dict:
raise NotImplementedError()
# vim:sw=4:ts=4:et:

View File

@ -0,0 +1,341 @@
import email
from typing import Optional, List, Dict, Union, Any, Tuple
from imapclient import IMAPClient
from imapclient.response_types import Address
from platypush.plugins import action
from platypush.plugins.mail import MailInPlugin, ServerInfo, Mail
class MailImapPlugin(MailInPlugin):
"""
Plugin to interact with a mail server over IMAP.
Requires:
* **imapclient** (``pip install imapclient``)
"""
_default_port = 143
_default_ssl_port = 993
def __init__(self, server: str, port: Optional[int] = None, username: Optional[str] = None,
password: Optional[str] = None, password_cmd: Optional[str] = None, access_token: Optional[str] = None,
oauth_mechanism: Optional[str] = 'XOAUTH2', oauth_vendor: Optional[str] = None, ssl: bool = False,
keyfile: Optional[str] = None, certfile: Optional[str] = None, timeout: Optional[int] = 60, **kwargs):
"""
:param server: Server name/address.
:param port: Port (default: 143 for plain, 993 for SSL).
:param username: IMAP username.
:param password: IMAP password.
:param password_cmd: If you don't want to input your password in the configuration, run this command to fetch
or decrypt the password.
:param access_token: OAuth2 access token if the server supports OAuth authentication.
:param oauth_mechanism: OAuth2 mechanism (default: ``XOAUTH2``).
:param oauth_vendor: OAuth2 vendor (default: None).
:param ssl: Use SSL (default: False).
:param keyfile: Private key file for SSL connection if client authentication is required.
:param certfile: SSL certificate file or chain.
:param timeout: Server connect/read timeout in seconds (default: 60).
"""
super().__init__(**kwargs)
self.server_info = self._get_server_info(server=server, port=port, username=username, password=password,
password_cmd=password_cmd, ssl=ssl, keyfile=keyfile, certfile=certfile,
access_token=access_token, oauth_mechanism=oauth_mechanism,
oauth_vendor=oauth_vendor, timeout=timeout)
def _get_server_info(self, server: Optional[str] = None, port: Optional[int] = None, username: Optional[str] = None,
password: Optional[str] = None, password_cmd: Optional[str] = None,
access_token: Optional[str] = None, oauth_mechanism: Optional[str] = None,
oauth_vendor: Optional[str] = None, ssl: Optional[bool] = None, keyfile: Optional[str] = None,
certfile: Optional[str] = None, timeout: Optional[int] = None, **kwargs) -> ServerInfo:
return super()._get_server_info(server=server, port=port, username=username, password=password,
password_cmd=password_cmd, ssl=ssl, keyfile=keyfile, certfile=certfile,
default_port=self._default_port, default_ssl_port=self._default_ssl_port,
access_token=access_token, oauth_mechanism=oauth_mechanism,
oauth_vendor=oauth_vendor, timeout=timeout)
def connect(self, **connect_args) -> IMAPClient:
info = self._get_server_info(**connect_args)
self.logger.info('Connecting to {}'.format(info.server))
context = None
if info.ssl:
import ssl
context = ssl.create_default_context()
context.load_cert_chain(certfile=info.certfile, keyfile=info.keyfile)
client = IMAPClient(host=info.server, port=info.port, ssl=info.ssl, ssl_context=context)
if info.password:
client.login(info.username, info.password)
elif info.access_token:
client.oauth2_login(info.username, access_token=info.access_token, mech=info.oauth_mechanism,
vendor=info.oauth_vendor)
return client
@staticmethod
def _get_folders(data: List[tuple]) -> List[Dict[str, str]]:
folders = []
for line in data:
(flags), delimiter, mailbox_name = line
folders.append({
'name': mailbox_name,
'flags': [flag.decode() for flag in flags],
'delimiter': delimiter.decode(),
})
return folders
@action
def get_folders(self, folder: str = '', pattern: str = '*', **connect_args) -> List[Dict[str, str]]:
"""
Get the list of all the folders hosted on the server or those matching a pattern.
:param folder: Base folder (default: root).
:param pattern: Pattern to search (default: None).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
:return: Example:
.. code-block:: json
[
{
"name": "INBOX",
"flags": "\\Noinferiors",
"delimiter": "/"
},
{
"name": "Archive",
"flags": "\\Noinferiors",
"delimiter": "/"
},
{
"name": "Spam",
"flags": "\\Noinferiors",
"delimiter": "/"
}
]
"""
with self.connect(**connect_args) as client:
data = client.list_folders(directory=folder, pattern=pattern)
return self._get_folders(data)
@action
def get_sub_folders(self, folder: str = '', pattern: str = '*', **connect_args) -> List[Dict[str, str]]:
"""
Get the list of all the sub-folders hosted on the server or those matching a pattern.
:param folder: Base folder (default: root).
:param pattern: Pattern to search (default: None).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
:return: Example:
.. code-block:: json
[
{
"name": "INBOX",
"flags": "\\Noinferiors",
"delimiter": "/"
},
{
"name": "Archive",
"flags": "\\Noinferiors",
"delimiter": "/"
},
{
"name": "Spam",
"flags": "\\Noinferiors",
"delimiter": "/"
}
]
"""
with self.connect(**connect_args) as client:
data = client.list_sub_folders(directory=folder, pattern=pattern)
return self._get_folders(data)
@staticmethod
def _parse_address(imap_addr: Address) -> Dict[str, str]:
return {
'name': imap_addr.name.decode() if imap_addr.name else None,
'route': imap_addr.route.decode() if imap_addr.route else None,
'email': '{name}@{host}'.format(name=imap_addr.mailbox.decode(), host=imap_addr.host.decode())
}
@classmethod
def _parse_addresses(cls, addresses: Optional[Tuple[Address]] = None) -> Dict[str, Dict[str, str]]:
ret = {}
if addresses:
for addr in addresses:
addr = cls._parse_address(addr)
ret[addr['email']] = addr
return ret
@classmethod
def _parse_message(cls, msg_id: int, imap_msg: Dict[bytes, Any]) -> Mail:
message = {
'id': msg_id,
'seq': imap_msg[b'SEQ'],
}
if imap_msg.get(b'FLAGS'):
message['flags'] = [flag.decode() for flag in imap_msg[b'FLAGS'] if flag]
if b'INTERNALDATE' in imap_msg:
message['internal_date'] = imap_msg[b'INTERNALDATE']
if b'RFC822.SIZE' in imap_msg:
message['size'] = imap_msg[b'RFC822.SIZE']
if b'ENVELOPE' in imap_msg:
envelope = imap_msg[b'ENVELOPE']
message['bcc'] = cls._parse_addresses(envelope.bcc)
message['cc'] = cls._parse_addresses(envelope.cc)
message['date'] = envelope.date
message['from'] = cls._parse_addresses(envelope.from_)
message['message_id'] = envelope.message_id.decode() if envelope.message_id else None
message['in_reply_to'] = envelope.in_reply_to.decode() if envelope.in_reply_to else None
message['reply_to'] = cls._parse_addresses(envelope.reply_to)
message['sender'] = cls._parse_addresses(envelope.sender)
message['subject'] = envelope.subject.decode() if envelope.subject else None
message['to'] = cls._parse_addresses(envelope.to)
return Mail(**message)
@action
def search(self, criteria: Union[str, List[str]] = 'ALL', folder: str = 'INBOX',
attributes: Optional[List[str]] = None, **connect_args) -> List[Mail]:
"""
Search for messages on the server that fit the specified criteria.
:param criteria: It should be a sequence of one or more criteria items. Each criteria item may be either unicode
or bytes (default: ``ALL``). Example values::
['UNSEEN']
['SMALLER', 500]
['NOT', 'DELETED']
['TEXT', 'foo bar', 'FLAGGED', 'SUBJECT', 'baz']
['SINCE', '2020-03-14T12:13:45+00:00']
It is also possible (but not recommended) to pass the combined criteria as a single string. In this case
IMAPClient won't perform quoting, allowing lower-level specification of criteria. Examples of this style::
'UNSEEN'
'SMALLER 500'
'NOT DELETED'
'TEXT "foo bar" FLAGGED SUBJECT "baz"'
'SINCE 03-Apr-2005'
To support complex search expressions, criteria lists can be nested. The following will match messages that
are both not flagged and do not have "foo" in the subject::
['NOT', ['SUBJECT', 'foo', 'FLAGGED']]
:param folder: Folder to search (default: ``INBOX``).
:param attributes: Attributes that should be retrieved, according to
`RFC 3501 <https://tools.ietf.org/html/rfc3501>`_
(default: ``ALL`` = ``[FLAGS INTERNALDATE RFC822.SIZE ENVELOPE]``).
Note that ``BODY`` will be ignored if specified here for performance reasons - use :meth:`.get_message` if
you want to get the full content of a message known its ID from :meth:`.search`.
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
:return: List of messages matching the criteria. Example:
.. code-block:: json
[
{
"id": 702,
"seq": 671,
"flags": [
"nonjunk"
],
"internal_date": "2020-08-30T00:31:52+00:00",
"size": 2908738,
"bcc": {},
"cc": {},
"date": "2020-08-30T00:31:52+00:00",
"from": {
"test123@gmail.com": {
"name": "A test",
"route": null,
"email": "test123@gmail.com"
}
},
"message_id": "<SOMETHING@mail.gmail.com>",
"in_reply_to": "<SOMETHING@mail.gmail.com>",
"reply_to": {},
"sender": {
"test123@gmail.com": {
"name": "A test",
"route": null,
"email": "test123@gmail.com"
}
},
"subject": "Test email",
"to": {
"me@gmail.com": {
"name": null,
"route": null,
"email": "me@gmail.com"
}
}
}
]
"""
if not attributes:
attributes = ['ALL']
else:
attributes = [attr.upper() for attr in attributes]
data = {}
with self.connect(**connect_args) as client:
client.select_folder(folder, readonly=True)
ids = client.search(criteria)
if len(ids):
data = client.fetch(list(ids), attributes)
messages = [
self._parse_message(msg_id, data[msg_id])
for msg_id in sorted(data.keys())
]
return messages
@action
def search_unseen_messages(self, folder: str = 'INBOX', **connect_args) -> List[Mail]:
"""
Shortcut for :meth:`.search` that returns only the unread messages.
"""
return self.search(criteria='UNSEEN', directory=folder, attributes=['ALL'], **connect_args)
@action
def get_message(self, id: int, folder: str = 'INBOX', **connect_args) -> Mail:
"""
Get the full content of a message given the ID returned by :meth:`.search`.
:param id: Message ID.
:param folder: Folder name (default: ``INBOX``).
:param connect_args: Arguments to pass to :meth:`._get_server_info` for server configuration override.
:return: A message in the same format as :meth:`.search`, with an added ``payload`` attribute containing the
body/payload.
"""
with self.connect(**connect_args) as client:
client.select_folder(folder, readonly=True)
data = client.fetch(id, ['ALL', 'RFC822'])
assert id in data, 'No such message ID: {}'.format(id)
data = data[id]
ret = self._parse_message(id, data)
msg = email.message_from_bytes(data[b'RFC822'])
ret.payload = msg.get_payload()
return ret
# vim:sw=4:ts=4:et:

View File

@ -293,3 +293,5 @@ croniter
# RPi.GPIO
# RPLCD
# Support for IMAP mail integration
# imapclient

View File

@ -330,5 +330,7 @@ setup(
'dht': ['Adafruit_Python_DHT @ git+https://github.com/adafruit/Adafruit_Python_DHT'],
# Support for LCD display integration
'lcd': ['RPi.GPIO', 'RPLCD'],
# Support for IMAP mail integration
'imap': ['imapclient'],
},
)