1103 lines
38 KiB
Python
1103 lines
38 KiB
Python
import os
|
|
import pathlib
|
|
import time
|
|
|
|
from collections import defaultdict
|
|
from threading import Thread, RLock
|
|
from typing import Any, Dict, List, Optional, Union, Collection
|
|
|
|
from platypush.config import Config
|
|
from platypush.message.event.mail import (
|
|
FlaggedMailEvent,
|
|
SeenMailEvent,
|
|
UnflaggedMailEvent,
|
|
UnseenMailEvent,
|
|
)
|
|
from platypush.plugins import RunnablePlugin, action
|
|
|
|
from ._account import Account
|
|
from ._model import FolderStatus, Mail, MailFlagType, AccountsStatus
|
|
from ._plugin import MailInPlugin, MailOutPlugin
|
|
|
|
AccountType = Union[str, int, Account]
|
|
AccountFolderChanges = Dict[str, Dict[MailFlagType, Dict[int, bool]]]
|
|
|
|
|
|
class MailPlugin(RunnablePlugin):
|
|
"""
|
|
Plugin to:
|
|
|
|
- Monitor one or more mailboxes, and emit events when new messages are
|
|
received, seen, flagged or unflagged.
|
|
|
|
- Send mail messages.
|
|
|
|
- Search for messages in a mailbox.
|
|
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
accounts: List[Dict[str, Any]],
|
|
timeout: float = 20.0,
|
|
poll_interval: float = 60.0,
|
|
**kwargs,
|
|
):
|
|
"""
|
|
Example accounts configuration:
|
|
|
|
.. code-block:: yaml
|
|
|
|
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
|
|
|
|
# Domain to be used for outgoing emails. Default: inferred
|
|
# from the account configuration
|
|
domain: example.com
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
- name: "GMail"
|
|
username: me@gmail.com
|
|
|
|
# Access token, if the mail server supports OAuth2
|
|
# access_token: my-access-token
|
|
|
|
# OAuth mechanism, if the mail server supports OAuth2
|
|
# (default: XOAUTH2)
|
|
# oauth_mechanism: XOAUTH2
|
|
|
|
# OAuth vendor, if the mail server supports OAuth2
|
|
# oauth_vendor: GOOGLE
|
|
|
|
incoming:
|
|
# Defaults to port 993 for IMAPS if no port is
|
|
# specified on the URL
|
|
server: imaps://imap.gmail.com
|
|
|
|
outgoing:
|
|
# Defaults to port 465 for SMTPS if no port is
|
|
# specified on the URL
|
|
server: smtps://smtp.gmail.com
|
|
|
|
monitor_folders:
|
|
- INBOX
|
|
- Sent
|
|
|
|
:param accounts: List of available mailboxes/accounts.
|
|
:param poll_interval: How often the plugin should poll for new messages
|
|
(default: 60 seconds).
|
|
:param timeout: Timeout for the mail server connection (default: 20
|
|
seconds).
|
|
"""
|
|
assert accounts, 'No mail accounts configured'
|
|
|
|
super().__init__(poll_interval=poll_interval, **kwargs)
|
|
self.timeout = timeout
|
|
self.accounts = self._parse_accounts(accounts)
|
|
self._accounts_by_name = {acc.name: acc for acc in self.accounts}
|
|
self._default_account = next(
|
|
(acc for acc in self.accounts if acc.default), self.accounts[0]
|
|
)
|
|
|
|
self._status = AccountsStatus()
|
|
self._db_lock = RLock()
|
|
self.workdir = os.path.join(Config.get_workdir(), 'mail')
|
|
self._status_file = os.path.join(self.workdir, 'status.json')
|
|
|
|
# Configure/sync db
|
|
pathlib.Path(self.workdir).mkdir(parents=True, exist_ok=True, mode=0o750)
|
|
self._load_status()
|
|
|
|
def _load_status(self):
|
|
with self._db_lock:
|
|
try:
|
|
with open(self._status_file) as f:
|
|
self._status = AccountsStatus.read(f)
|
|
except FileNotFoundError:
|
|
self._status = AccountsStatus()
|
|
except Exception as e:
|
|
self.logger.warning(
|
|
'Could not load mail status from %s: %s', self._status_file, e
|
|
)
|
|
self._status = AccountsStatus()
|
|
|
|
def _get_account(self, account: Optional[AccountType] = None) -> Account:
|
|
if isinstance(account, Account):
|
|
return account
|
|
|
|
if isinstance(account, int):
|
|
account -= 1
|
|
assert (
|
|
0 <= account < len(self.accounts)
|
|
), f'Invalid account index {account} (valid range: 1-{len(self.accounts)})'
|
|
|
|
return self.accounts[account]
|
|
|
|
if isinstance(account, str):
|
|
acc = self._accounts_by_name.get(account)
|
|
assert acc, f'No account found with name "{account}"'
|
|
return acc
|
|
|
|
return self._default_account
|
|
|
|
def _get_in_plugin(self, account: Optional[AccountType] = None) -> MailInPlugin:
|
|
acc = self._get_account(account)
|
|
assert acc.incoming, f'No incoming configuration found for account "{acc.name}"'
|
|
return acc.incoming
|
|
|
|
def _get_out_plugin(self, account: Optional[AccountType] = None) -> MailOutPlugin:
|
|
acc = self._get_account(account)
|
|
assert acc.outgoing, f'No outgoing configuration found for account "{acc.name}"'
|
|
return acc.outgoing
|
|
|
|
def _parse_accounts(self, accounts: List[Dict[str, Any]]) -> List[Account]:
|
|
ret = []
|
|
for i, acc in enumerate(accounts):
|
|
idx = i + 1
|
|
name = acc.pop('name') if 'name' in acc else f'Account #{idx}'
|
|
incoming_conf = acc.pop('incoming')
|
|
outgoing_conf = acc.pop('outgoing')
|
|
monitor_folders = acc.pop('monitor_folders', [])
|
|
|
|
assert (
|
|
incoming_conf or outgoing_conf
|
|
), f'No incoming/outgoing configuration specified for account "{name}"'
|
|
|
|
if monitor_folders:
|
|
assert incoming_conf, (
|
|
f'Cannot monitor folders for account "{name}" '
|
|
'without incoming configuration'
|
|
)
|
|
|
|
acc['poll_interval'] = self.poll_interval
|
|
acc['timeout'] = acc.get('timeout', self.timeout)
|
|
ret.append(
|
|
Account.build(
|
|
name=name,
|
|
incoming=incoming_conf,
|
|
outgoing=outgoing_conf,
|
|
monitor_folders=monitor_folders,
|
|
**acc,
|
|
)
|
|
)
|
|
|
|
return ret
|
|
|
|
@property
|
|
def _monitored_accounts(self):
|
|
return [acc for acc in self.accounts if acc.monitor_folders]
|
|
|
|
@property
|
|
def _account_by_name(self) -> Dict[str, Account]:
|
|
return {acc.name: acc for acc in self.accounts}
|
|
|
|
@staticmethod
|
|
def _check_thread(plugin: MailInPlugin, folder: str, results: FolderStatus):
|
|
results[MailFlagType.UNREAD] = {
|
|
msg.id: msg for msg in plugin.search_unseen_messages(folder=folder)
|
|
}
|
|
|
|
results[MailFlagType.FLAGGED] = {
|
|
msg.id: msg for msg in plugin.search_flagged_messages(folder=folder)
|
|
}
|
|
|
|
def _check_mailboxes(self) -> AccountsStatus:
|
|
# Workers indexed by (account_name, folder) -> thread
|
|
workers = {}
|
|
status = AccountsStatus()
|
|
|
|
for account in self._monitored_accounts:
|
|
for folder in account.monitor_folders or []:
|
|
worker = Thread(
|
|
target=self._check_thread,
|
|
name=f'check-mailbox-{account.name}-{folder}',
|
|
kwargs={
|
|
'plugin': account.incoming,
|
|
'results': status[account.name][folder],
|
|
'folder': folder,
|
|
},
|
|
)
|
|
|
|
worker.start()
|
|
workers[account.name, folder] = worker
|
|
|
|
wait_start = time.time()
|
|
for worker_key, worker in workers.items():
|
|
account = self._account_by_name[worker_key[0]]
|
|
if not account.incoming:
|
|
continue
|
|
|
|
# The timeout should be the time elapsed since wait_start + the configured timeout
|
|
timeout = max(
|
|
0, account.incoming.server.timeout - (time.time() - wait_start)
|
|
)
|
|
worker.join(timeout=timeout)
|
|
if worker.is_alive():
|
|
self.logger.warning('Timeout while polling account %s', account.name)
|
|
|
|
return status
|
|
|
|
@action
|
|
def get_folders(
|
|
self,
|
|
folder: str = '',
|
|
pattern: str = '*',
|
|
account: Optional[AccountType] = None,
|
|
) -> List[Dict[str, str]]:
|
|
r"""
|
|
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 account: Account name or index (default: default account).
|
|
:return: Example:
|
|
|
|
.. code-block:: json
|
|
|
|
[
|
|
{
|
|
"name": "INBOX",
|
|
"flags": "\\Noinferiors",
|
|
"delimiter": "/"
|
|
},
|
|
{
|
|
"name": "Archive",
|
|
"flags": "\\Noinferiors",
|
|
"delimiter": "/"
|
|
},
|
|
{
|
|
"name": "Spam",
|
|
"flags": "\\Noinferiors",
|
|
"delimiter": "/"
|
|
}
|
|
]
|
|
|
|
"""
|
|
return self._get_in_plugin(account).get_folders(folder=folder, pattern=pattern)
|
|
|
|
@action
|
|
def get_sub_folders(
|
|
self,
|
|
folder: str = '',
|
|
pattern: str = '*',
|
|
account: Optional[AccountType] = None,
|
|
) -> List[Dict[str, str]]:
|
|
r"""
|
|
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 account: Account name or index (default: default account).
|
|
:return: Example:
|
|
|
|
.. code-block:: json
|
|
|
|
[
|
|
{
|
|
"name": "INBOX",
|
|
"flags": "\\Noinferiors",
|
|
"delimiter": "/"
|
|
},
|
|
{
|
|
"name": "Archive",
|
|
"flags": "\\Noinferiors",
|
|
"delimiter": "/"
|
|
},
|
|
{
|
|
"name": "Spam",
|
|
"flags": "\\Noinferiors",
|
|
"delimiter": "/"
|
|
}
|
|
]
|
|
|
|
"""
|
|
return self._get_in_plugin(account).get_sub_folders(
|
|
folder=folder, pattern=pattern
|
|
)
|
|
|
|
@action
|
|
def search(
|
|
self,
|
|
criteria: Union[str, List[str]] = 'ALL',
|
|
folder: str = 'INBOX',
|
|
attributes: Optional[List[str]] = None,
|
|
account: Optional[AccountType] = None,
|
|
) -> List[Mail]:
|
|
"""
|
|
Search for messages on the server that fit the specified criteria.
|
|
|
|
If no criteria is specified, then all the messages in the folder will
|
|
be returned.
|
|
|
|
:param criteria: It should be a sequence of one or more criteria items.
|
|
Each criterion item may be either unicode or bytes (default:
|
|
``ALL``). Example values::
|
|
|
|
['UNSEEN']
|
|
['FROM', 'me@example.com']
|
|
['TO', 'me@example.com']
|
|
['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']]
|
|
|
|
See :rfc:`3501#section-6.4.4` for more details.
|
|
|
|
: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 account: Account name or index (default: default account).
|
|
: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"
|
|
}
|
|
}
|
|
}
|
|
]
|
|
|
|
"""
|
|
return self._get_in_plugin(account).search(
|
|
criteria=criteria, folder=folder, attributes=attributes
|
|
)
|
|
|
|
@action
|
|
def search_unseen_messages(
|
|
self, folder: str = 'INBOX', account: Optional[AccountType] = None
|
|
) -> List[Mail]:
|
|
"""
|
|
Shortcut for :meth:`.search` that returns only the unread messages.
|
|
"""
|
|
return self._get_in_plugin(account).search_unseen_messages(folder=folder)
|
|
|
|
@action
|
|
def search_flagged_messages(
|
|
self, folder: str = 'INBOX', account: Optional[AccountType] = None
|
|
) -> List[Mail]:
|
|
"""
|
|
Shortcut for :meth:`.search` that returns only the flagged/starred messages.
|
|
"""
|
|
return self._get_in_plugin(account).search_flagged_messages(folder=folder)
|
|
|
|
@action
|
|
def search_starred_messages(
|
|
self, folder: str = 'INBOX', account: Optional[AccountType] = None
|
|
) -> List[Mail]:
|
|
"""
|
|
Shortcut for :meth:`.search` that returns only the starred messages.
|
|
"""
|
|
return self._get_in_plugin(account).search_starred_messages(folder=folder)
|
|
|
|
@action
|
|
def sort(
|
|
self,
|
|
folder: str = 'INBOX',
|
|
sort_criteria: Union[str, List[str]] = 'ARRIVAL',
|
|
criteria: Union[str, List[str]] = 'ALL',
|
|
account: Optional[AccountType] = None,
|
|
) -> List[int]:
|
|
"""
|
|
Return a list of message ids from the currently selected folder, sorted
|
|
by ``sort_criteria`` and optionally filtered by ``criteria``. Note that
|
|
SORT is an extension to the IMAP4 standard, so it may not be supported
|
|
by all IMAP servers.
|
|
|
|
:param folder: Folder to be searched (default: ``INBOX``).
|
|
:param sort_criteria: It may be a sequence of strings or a single
|
|
string. IMAPClient will take care any required conversions. Valid
|
|
*sort_criteria* values::
|
|
|
|
.. code-block:: python
|
|
|
|
['ARRIVAL']
|
|
['SUBJECT', 'ARRIVAL']
|
|
'ARRIVAL'
|
|
'REVERSE SIZE'
|
|
|
|
:param criteria: Optional filter for the messages, as specified in
|
|
:meth:`.search`.
|
|
:param account: Account name or index (default: default account).
|
|
:return: A list of message IDs that fit the criteria.
|
|
"""
|
|
return self._get_in_plugin(account).sort(
|
|
folder=folder, sort_criteria=sort_criteria, criteria=criteria
|
|
)
|
|
|
|
@action
|
|
def get_message(
|
|
self,
|
|
id: int, # pylint: disable=redefined-builtin
|
|
folder: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
with_body: bool = True,
|
|
) -> Mail:
|
|
r"""
|
|
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 account: Account name or index (default: default account).
|
|
:param with_body: If set then the body/payload will be included in the response (default: ``True``).
|
|
:return: A message in the same format as :meth:`.search`, with an added
|
|
``payload`` attribute containing the body/payload. Example response:
|
|
|
|
.. code-block:: json
|
|
|
|
{
|
|
"id": 123,
|
|
"date": "2024-01-13T21:04:50",
|
|
"size": 3833,
|
|
"from": {
|
|
"you@example.com": {
|
|
"name": "Me",
|
|
"route": null,
|
|
"email": "you@example.com"
|
|
}
|
|
},
|
|
"to": {
|
|
"me@example.com": {
|
|
"name": "Me",
|
|
"route": null,
|
|
"email": "me@example.com"
|
|
}
|
|
},
|
|
"cc": {
|
|
"they@example.com": {
|
|
"name": "They",
|
|
"route": null,
|
|
"email": "they@example.com"
|
|
}
|
|
},
|
|
"bcc": {
|
|
"boss@example.com": {
|
|
"name": "Boss",
|
|
"route": null,
|
|
"email": "boss@example.com"
|
|
}
|
|
},
|
|
"subject": "Test email",
|
|
"content": {
|
|
"headers": {
|
|
"Return-Path": "<you@example.com>",
|
|
"Delivered-To": "me@example.com",
|
|
"Date": "Sat, 13 Jan 2024 20:04:50 +0000",
|
|
"To": "Me <me@example.com>",
|
|
"Cc": "They <they@example.com>",
|
|
"Bcc": "Boss <boss@example.com",
|
|
"From": "You <you@example.com>",
|
|
"Subject": "Test email",
|
|
"Message-ID": "<0123456789@client>",
|
|
"MIME-Version": "1.0",
|
|
"Content-Type": "multipart/mixed;\r\n boundary=\"0123456789\""
|
|
},
|
|
"body": "This is the email body",
|
|
"attachments": [
|
|
{
|
|
"filename": "signature.asc",
|
|
"headers": {
|
|
"Content-Type": "application/pgp-signature; name=signature.asc",
|
|
"Content-Transfer-Encoding": "base64",
|
|
"Content-Disposition": "attachment; filename=signature.asc"
|
|
},
|
|
"body": "-----BEGIN PGP SIGNATURE-----\r\n\r\n....\r\n\r\n-----END PGP SIGNATURE-----\r\n"
|
|
},
|
|
{
|
|
"filename": "image.jpg",
|
|
"body": "/9j/4gIcSUNDX1BST0ZJTEUAA...",
|
|
"headers": {
|
|
"Content-Type": "image/jpeg; Name=\"image.jpg\"",
|
|
"MIME-Version": "1.0",
|
|
"Content-Transfer-Encoding": "base64",
|
|
"Content-Disposition": "attachment; filename=\"profile_pic.jpg\""
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"seq": 123,
|
|
"internal_date": "2024-01-13T21:05:12",
|
|
"message_id": "<0123456789@client>",
|
|
"reply_to": {
|
|
"you@example.com": {
|
|
"name": "You",
|
|
"route": null,
|
|
"email": "you@example.com"
|
|
}
|
|
},
|
|
"sender": {
|
|
"you@example.com": {
|
|
"name": "You",
|
|
"route": null,
|
|
"email": "you@example.com"
|
|
}
|
|
}
|
|
}
|
|
|
|
"""
|
|
return self._get_in_plugin(account).get_message(
|
|
id=id, folder=folder, with_body=with_body
|
|
)
|
|
|
|
@action
|
|
def get_messages(
|
|
self,
|
|
ids: Collection[int],
|
|
folder: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
with_body: bool = True,
|
|
) -> Dict[int, Mail]:
|
|
"""
|
|
Get the full content of a list of messages given their IDs returned by :meth:`.search`.
|
|
|
|
:param ids: IDs of the messages to retrieve.
|
|
:param folder: Folder name (default: ``INBOX``).
|
|
:param account: Account name or index (default: default account).
|
|
:param with_body: If set then the body/payload will be included in the response
|
|
(default: ``True``).
|
|
:return: A dictionary in the format ``{id -> msg}``, where ``msg`` is in the same
|
|
format as :meth:`.search`, with an added ``payload`` attribute containing the
|
|
body/payload. See :meth:`.get_message` for an example message format.
|
|
"""
|
|
return self._get_in_plugin(account).get_messages(
|
|
*ids, folder=folder, with_body=with_body
|
|
)
|
|
|
|
@action
|
|
def create_folder(self, folder: str, account: Optional[AccountType] = None):
|
|
"""
|
|
Create a folder on the server.
|
|
|
|
:param folder: Folder name.
|
|
:param account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).create_folder(folder=folder)
|
|
|
|
@action
|
|
def rename_folder(
|
|
self, old_name: str, new_name: str, account: Optional[AccountType] = None
|
|
):
|
|
"""
|
|
Rename a folder on the server.
|
|
|
|
:param old_name: Previous name
|
|
:param new_name: New name
|
|
:param account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).rename_folder(
|
|
old_name=old_name, new_name=new_name
|
|
)
|
|
|
|
@action
|
|
def delete_folder(self, folder: str, account: Optional[AccountType] = None):
|
|
"""
|
|
Delete a folder from the server.
|
|
|
|
:param folder: Folder name.
|
|
:param account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).delete_folder(folder=folder)
|
|
|
|
@action
|
|
def add_flags(
|
|
self,
|
|
messages: List[int],
|
|
flags: Union[str, List[str]],
|
|
folder: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
):
|
|
"""
|
|
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 account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).add_flags(
|
|
messages=messages, flags=flags, folder=folder
|
|
)
|
|
|
|
@action
|
|
def set_flags(
|
|
self,
|
|
messages: List[int],
|
|
flags: Union[str, List[str]],
|
|
folder: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
):
|
|
"""
|
|
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 account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).set_flags(
|
|
messages=messages, flags=flags, folder=folder
|
|
)
|
|
|
|
@action
|
|
def remove_flags(
|
|
self,
|
|
messages: List[int],
|
|
flags: Union[str, List[str]],
|
|
folder: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
):
|
|
"""
|
|
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 account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).remove_flags(
|
|
messages=messages, flags=flags, folder=folder
|
|
)
|
|
|
|
@action
|
|
def flag_messages(
|
|
self,
|
|
messages: List[int],
|
|
folder: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
):
|
|
"""
|
|
Add a flag/star to the specified set of message IDs.
|
|
|
|
:param messages: List of message IDs.
|
|
:param folder: IMAP folder (default: ``INBOX``).
|
|
:param account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).flag_messages(
|
|
messages=messages, folder=folder
|
|
)
|
|
|
|
@action
|
|
def unflag_messages(
|
|
self,
|
|
messages: List[int],
|
|
folder: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
):
|
|
"""
|
|
Remove a flag/star from the specified set of message IDs.
|
|
|
|
:param messages: List of message IDs.
|
|
:param folder: IMAP folder (default: ``INBOX``).
|
|
:param account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).unflag_messages(
|
|
messages=messages, folder=folder
|
|
)
|
|
|
|
@action
|
|
def flag_message(
|
|
self, message: int, folder: str = 'INBOX', account: Optional[AccountType] = None
|
|
):
|
|
"""
|
|
Add a flag/star to the specified set of message ID.
|
|
|
|
:param message: Message ID.
|
|
:param folder: IMAP folder (default: ``INBOX``).
|
|
:param account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).flag_message(message=message, folder=folder)
|
|
|
|
@action
|
|
def unflag_message(
|
|
self, message: int, folder: str = 'INBOX', account: Optional[AccountType] = None
|
|
):
|
|
"""
|
|
Remove a flag/star from the specified set of message ID.
|
|
|
|
:param message: Message ID.
|
|
:param folder: IMAP folder (default: ``INBOX``).
|
|
:param account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).unflag_message(
|
|
message=message, folder=folder
|
|
)
|
|
|
|
@action
|
|
def delete_messages(
|
|
self,
|
|
messages: List[int],
|
|
folder: str = 'INBOX',
|
|
expunge: bool = True,
|
|
account: Optional[AccountType] = None,
|
|
):
|
|
"""
|
|
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 account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).delete_messages(
|
|
messages=messages, folder=folder, expunge=expunge
|
|
)
|
|
|
|
@action
|
|
def restore_messages(
|
|
self,
|
|
messages: List[int],
|
|
folder: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
):
|
|
"""
|
|
Remove the ``Deleted`` flag from the specified set of message IDs.
|
|
|
|
:param messages: List of message IDs.
|
|
:param folder: IMAP folder (default: ``INBOX``).
|
|
:param account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).restore_messages(
|
|
messages=messages, folder=folder
|
|
)
|
|
|
|
@action
|
|
def copy_messages(
|
|
self,
|
|
messages: List[int],
|
|
destination: str,
|
|
source: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
):
|
|
"""
|
|
Copy a set of messages IDs from a folder to another.
|
|
|
|
:param messages: List of message IDs.
|
|
:param source: Source folder.
|
|
:param destination: Destination folder.
|
|
:param account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).copy_messages(
|
|
messages=messages, dest_folder=destination, source_folder=source
|
|
)
|
|
|
|
@action
|
|
def move_messages(
|
|
self,
|
|
messages: List[int],
|
|
destination: str,
|
|
source: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
):
|
|
"""
|
|
Move a set of messages IDs from a folder to another.
|
|
|
|
:param messages: List of message IDs.
|
|
:param source: Source folder.
|
|
:param destination: Destination folder.
|
|
:param account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).move_messages(
|
|
messages=messages, dest_folder=destination, source_folder=source
|
|
)
|
|
|
|
@action
|
|
def expunge_messages(
|
|
self,
|
|
messages: List[int],
|
|
folder: str = 'INBOX',
|
|
account: Optional[AccountType] = None,
|
|
):
|
|
"""
|
|
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 account: Account name or index (default: default account).
|
|
"""
|
|
return self._get_in_plugin(account).expunge_messages(
|
|
folder=folder, messages=messages
|
|
)
|
|
|
|
@action
|
|
def send(
|
|
self,
|
|
to: Union[str, List[str]],
|
|
from_: Optional[str] = None,
|
|
cc: Optional[Union[str, List[str]]] = None,
|
|
bcc: Optional[Union[str, List[str]]] = None,
|
|
subject: str = '',
|
|
body: str = '',
|
|
body_type: str = 'plain',
|
|
attachments: Optional[List[str]] = None,
|
|
headers: Optional[Dict[str, str]] = None,
|
|
account: Optional[AccountType] = None,
|
|
**kwargs,
|
|
):
|
|
"""
|
|
Send an email through the specified SMTP sender.
|
|
|
|
:param to: Receiver(s), as comma-separated strings or list.
|
|
:param from_: Sender email address (``from`` is also supported).
|
|
:param cc: Carbon-copy addresses, as comma-separated strings or list
|
|
:param bcc: Blind carbon-copy addresses, as comma-separated strings or list
|
|
:param subject: Mail subject.
|
|
:param body: Mail body.
|
|
:param body_type: Mail body type, as a subtype of ``text/`` (e.g. ``html``) (default: ``plain``).
|
|
:param attachments: List of attachment files to send.
|
|
:param headers: Key-value map of headers to be added.
|
|
:param account: Account name/index to be used (default: default account).
|
|
"""
|
|
plugin = self._get_out_plugin(account)
|
|
headers = {k.lower(): v for k, v in (headers or {}).items()}
|
|
cc = ([cc] if isinstance(cc, str) else cc) or []
|
|
bcc = ([bcc] if isinstance(bcc, str) else bcc) or []
|
|
return plugin.send(
|
|
to=to,
|
|
from_=(
|
|
from_
|
|
or kwargs.get('from')
|
|
or (headers or {}).get('from')
|
|
or plugin.account.display_name
|
|
or plugin.account.username
|
|
),
|
|
cc=cc,
|
|
bcc=bcc,
|
|
subject=subject,
|
|
body=body,
|
|
body_type=body_type,
|
|
attachments=attachments,
|
|
headers=headers,
|
|
)
|
|
|
|
@staticmethod
|
|
def _process_account_changes(
|
|
account: str, cur_status: AccountsStatus, new_status: AccountsStatus
|
|
) -> AccountFolderChanges:
|
|
folders = new_status.get(account) or {}
|
|
mail_flag_changed: Dict[str, Dict[MailFlagType, Dict[int, bool]]] = defaultdict(
|
|
lambda: defaultdict(lambda: defaultdict(bool))
|
|
)
|
|
|
|
for folder, folder_status in folders.items():
|
|
for flag, new_mail in folder_status.items():
|
|
cur_mail = (cur_status.get(account) or {}).get(folder, {}).get(flag, {})
|
|
cur_mail_keys = set(map(int, cur_mail.keys()))
|
|
new_mail_keys = set(map(int, new_mail.keys()))
|
|
mail_flag_added_keys = new_mail_keys - cur_mail_keys
|
|
mail_flag_removed_keys = cur_mail_keys - new_mail_keys
|
|
mail_flag_changed[folder][flag].update(
|
|
{
|
|
**{msg_id: True for msg_id in mail_flag_added_keys},
|
|
**{msg_id: False for msg_id in mail_flag_removed_keys},
|
|
}
|
|
)
|
|
|
|
return mail_flag_changed
|
|
|
|
def _generate_account_events(
|
|
self, account: str, msgs: Dict[int, Mail], folder_changes: AccountFolderChanges
|
|
):
|
|
for folder, changes in folder_changes.items():
|
|
for flag, flag_changes in changes.items():
|
|
for msg_id, flag_added in flag_changes.items():
|
|
msg = msgs.get(msg_id)
|
|
if not msg:
|
|
continue
|
|
|
|
if flag == MailFlagType.UNREAD:
|
|
evt_type = UnseenMailEvent if flag_added else SeenMailEvent
|
|
elif flag == MailFlagType.FLAGGED:
|
|
evt_type = (
|
|
FlaggedMailEvent if flag_added else UnflaggedMailEvent
|
|
)
|
|
else:
|
|
continue
|
|
|
|
self._bus.post(
|
|
evt_type(
|
|
account=account,
|
|
folder=folder,
|
|
message=msg,
|
|
)
|
|
)
|
|
|
|
def _generate_events(self, cur_status: AccountsStatus, new_status: AccountsStatus):
|
|
for account in new_status:
|
|
mail_flag_changed = self._process_account_changes(
|
|
account, cur_status, new_status
|
|
)
|
|
|
|
msg_ids = {
|
|
msg_id
|
|
for _, changes in mail_flag_changed.items()
|
|
for _, flag_changes in changes.items()
|
|
for msg_id, _ in flag_changes.items()
|
|
}
|
|
|
|
if not msg_ids:
|
|
continue
|
|
|
|
acc = self._get_account(account)
|
|
if not acc.incoming:
|
|
continue
|
|
|
|
msgs = acc.incoming.get_messages(*msg_ids, with_body=False)
|
|
self._generate_account_events(account, msgs, mail_flag_changed)
|
|
|
|
def _update_status(self, status: AccountsStatus):
|
|
self._status = status
|
|
with open(self._status_file, 'w') as f:
|
|
status.write(f)
|
|
|
|
def loop(self):
|
|
cur_status = self._status.copy()
|
|
new_status = self._check_mailboxes()
|
|
self._generate_events(cur_status, new_status)
|
|
self._update_status(new_status)
|
|
|
|
def main(self):
|
|
while not self.should_stop():
|
|
try:
|
|
self.loop()
|
|
except Exception as e:
|
|
self.logger.exception(e)
|
|
finally:
|
|
self.wait_stop(self.poll_interval)
|
|
|
|
|
|
# vim:sw=4:ts=4:et:
|