From cefbcdebd23ce91d6e8d400d49c2d5391a4f0f68 Mon Sep 17 00:00:00 2001 From: Pietro Mingo <50447537+skar395@users.noreply.github.com> Date: Mon, 16 Jan 2023 22:23:38 +0100 Subject: [PATCH 1/2] Office365 API mailbox support (#251) * Update models.py, __init__.py, and office365.py * Update setup.py * Office365Transport properties * Update .gitignore * wrong folder stage * Update models.py * Update office365.py and models.py * office365 implementation * Update office365.py * clear code * fix import * Update readme.rst * archive and delete * fix and documentation * removed build folder * missing archive Co-authored-by: Pietro Mingo Co-authored-by: Serafim Bordei --- .gitignore | 1 + django_mailbox/models.py | 34 ++++++++++++- django_mailbox/transports/__init__.py | 1 + django_mailbox/transports/base.py | 1 + django_mailbox/transports/office365.py | 66 ++++++++++++++++++++++++++ docs/topics/mailbox_types.rst | 20 ++++++++ readme.rst | 2 +- setup.py | 7 ++- 8 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 django_mailbox/transports/office365.py diff --git a/.gitignore b/.gitignore index 804fef4..94fab77 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ dummy_project/* .tox/ messages *.sqlite3 +.idea/ diff --git a/django_mailbox/models.py b/django_mailbox/models.py index 0f0ac9d..42296e9 100644 --- a/django_mailbox/models.py +++ b/django_mailbox/models.py @@ -31,7 +31,7 @@ from django_mailbox import utils from django_mailbox.signals import message_received from django_mailbox.transports import Pop3Transport, ImapTransport, \ MaildirTransport, MboxTransport, BabylTransport, MHTransport, \ - MMDFTransport, GmailImapTransport + MMDFTransport, GmailImapTransport, Office365Transport logger = logging.getLogger(__name__) @@ -189,6 +189,30 @@ class Mailbox(models.Model): return None return folder[0] + @property + def client_id(self): + """Returns (if specified) the client id for Office365.""" + client_id = self._query_string.get('client_id', None) + if not client_id: + return None + return client_id[0] + + @property + def client_secret(self): + """Returns (if specified) the client secret for Office365.""" + client_secret = self._query_string.get('client_secret', None) + if not client_secret: + return None + return client_secret[0] + + @property + def tenant_id(self): + """Returns (if specified) the tenant id for Office365.""" + tenant_id = self._query_string.get('tenant_id', None) + if not tenant_id: + return None + return tenant_id[0] + def get_connection(self): """Returns the transport instance for this mailbox. @@ -223,6 +247,14 @@ class Mailbox(models.Model): ssl=self.use_ssl ) conn.connect(self.username, self.password) + elif self.type == 'office365': + conn = Office365Transport( + self.location, + self.username, + folder=self.folder, + archive=self.archive + ) + conn.connect(self.client_id, self.client_secret, self.tenant_id) elif self.type == 'maildir': conn = MaildirTransport(self.location) elif self.type == 'mbox': diff --git a/django_mailbox/transports/__init__.py b/django_mailbox/transports/__init__.py index 7aeef95..9f733e3 100644 --- a/django_mailbox/transports/__init__.py +++ b/django_mailbox/transports/__init__.py @@ -8,3 +8,4 @@ from django_mailbox.transports.babyl import BabylTransport from django_mailbox.transports.mh import MHTransport from django_mailbox.transports.mmdf import MMDFTransport from django_mailbox.transports.gmail import GmailImapTransport +from django_mailbox.transports.office365 import Office365Transport diff --git a/django_mailbox/transports/base.py b/django_mailbox/transports/base.py index a90af9f..a3a2bc0 100644 --- a/django_mailbox/transports/base.py +++ b/django_mailbox/transports/base.py @@ -9,3 +9,4 @@ class EmailTransport: message = email.message_from_bytes(contents) return message + diff --git a/django_mailbox/transports/office365.py b/django_mailbox/transports/office365.py new file mode 100644 index 0000000..4f748b2 --- /dev/null +++ b/django_mailbox/transports/office365.py @@ -0,0 +1,66 @@ +import logging + +from django.conf import settings + +from .base import EmailTransport, MessageParseError + +logger = logging.getLogger(__name__) + + +class Office365Transport(EmailTransport): + def __init__( + self, hostname, username, archive='', folder=None + ): + self.integration_testing_subject = getattr( + settings, + 'DJANGO_MAILBOX_INTEGRATION_TESTING_SUBJECT', + None + ) + self.hostname = hostname + self.username = username + self.archive = archive + self.folder = folder + + def connect(self, client_id, client_secret, tenant_id): + try: + import O365 + except ImportError: + raise ValueError( + "Install o365 to use oauth2 auth for office365" + ) + + credentials = (client_id, client_secret) + + self.account = O365.Account(credentials, auth_flow_type='credentials', tenant_id=tenant_id) + self.account.authenticate() + + self.mailbox = self.account.mailbox(resource=self.username) + self.mailbox_folder = self.mailbox.inbox_folder() + if self.folder: + self.mailbox_folder = self.mailbox.get_folder(folder_name=self.folder) + + def get_message(self, condition=None): + archive_folder = None + if self.archive: + archive_folder = self.mailbox.get_folder(folder_name=self.archive) + if not archive_folder: + archive_folder = self.mailbox.create_child_folder(self.archive) + + for o365message in self.mailbox_folder.get_messages(order_by='receivedDateTime'): + try: + mime_content = o365message.get_mime_content() + message = self.get_email_from_bytes(mime_content) + + if condition and not condition(message): + continue + + yield message + except MessageParseError: + continue + + if self.archive and archive_folder: + o365message.copy(archive_folder) + + o365message.delete() + return + diff --git a/docs/topics/mailbox_types.rst b/docs/topics/mailbox_types.rst index ad8c9ff..2d5a457 100644 --- a/docs/topics/mailbox_types.rst +++ b/docs/topics/mailbox_types.rst @@ -13,6 +13,7 @@ POP3 and IMAP as well as local file-based mailboxes. POP3 ``pop3://`` Can also specify SSL with ``pop3+ssl://`` IMAP ``imap://`` Can also specify SSL with ``imap+ssl://`` or STARTTLS with ``imap+tls``; additional configuration is also possible: see :ref:`pop3-and-imap-mailboxes` for details. Gmail IMAP ``gmail+ssl://`` Uses OAuth authentication for Gmail's IMAP transport. See :ref:`gmail-oauth` for details. + Office365 API``office365://`` Uses OAuth authentication for Office365 API transport. See :ref:`office365-oauth` for details. Maildir ``maildir://`` Mbox ``mbox://`` Babyl ``babyl://`` @@ -115,6 +116,25 @@ Build your URI accordingly:: gmail+ssl://youremailaddress%40gmail.com:oauth2@imap.gmail.com?archive=Archived +.. _office365-oauth: +Office 365 API +------------------------------------- + +Office 365 allows through the API to read a mailbox with Oauth. +The O365_ library is used. + +.. _O365: https://github.com/O365/python-o365 +.. _configuration: https://github.com/O365/python-o365#authentication + +For the configuration_ you need to register an application and get a client_id, client_secret and tenant_id. + +This implementation uses the client credentials grant flow and the password you specify will be ignored. + +Build your URI accordingly:: + + office365://youremailaddress%40yourdomain.com:oauth2@outlook.office365.com?client_id=client_id&client_secret=client_secret&tenant_id=tenant_id&archive=Archived + + Local File-based Mailboxes -------------------------- diff --git a/readme.rst b/readme.rst index 0aac06b..f03459e 100644 --- a/readme.rst +++ b/readme.rst @@ -8,7 +8,7 @@ :target: https://pypi.python.org/pypi/django-mailbox -Easily ingest messages from POP3, IMAP, or local mailboxes into your Django application. +Easily ingest messages from POP3, IMAP, Office365 API or local mailboxes into your Django application. This app allows you to either ingest e-mail content from common e-mail services (as long as the service provides POP3 or IMAP support), or directly receive e-mail messages from ``stdin`` (for locally processing messages from Postfix or Exim4). diff --git a/setup.py b/setup.py index 2460dc0..a6ade7d 100755 --- a/setup.py +++ b/setup.py @@ -13,6 +13,10 @@ gmail_oauth2_require = [ 'python-social-auth', ] +office365_oauth2_require = [ + 'O365', +] + setup( name='django-mailbox', version=version_string, @@ -25,7 +29,8 @@ setup( author='Adam Coddington', author_email='me@adamcoddington.net', extras_require={ - 'gmail-oauth2': gmail_oauth2_require + 'gmail-oauth2': gmail_oauth2_require, + 'office365-oauth2': office365_oauth2_require }, python_requires=">=3", classifiers=[ From 462fdd3e496e28cc7c9d99a4f18ce80cb8b6aba6 Mon Sep 17 00:00:00 2001 From: Jace Manshadi Date: Tue, 24 Jan 2023 06:08:47 -0800 Subject: [PATCH 2/2] updating location of logic for getmail command (#264) * moving logic for processing all the new emails from all mailboxes to a method under Mailbox so it can be called from other modules * moving logger lines into handle method as requested --- django_mailbox/management/commands/getmail.py | 23 ++----------------- django_mailbox/models.py | 21 +++++++++++++++++ 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/django_mailbox/management/commands/getmail.py b/django_mailbox/management/commands/getmail.py index 31b2dbd..f657509 100644 --- a/django_mailbox/management/commands/getmail.py +++ b/django_mailbox/management/commands/getmail.py @@ -5,26 +5,7 @@ from django.core.management.base import BaseCommand from django_mailbox.models import Mailbox -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - - class Command(BaseCommand): def handle(self, *args, **options): - mailboxes = Mailbox.active_mailboxes.all() - if args: - mailboxes = mailboxes.filter( - name=' '.join(args) - ) - for mailbox in mailboxes: - logger.info( - 'Gathering messages for %s', - mailbox.name - ) - messages = mailbox.get_new_mail() - for message in messages: - logger.info( - 'Received %s (from %s)', - message.subject, - message.from_address - ) + logging.basicConfig(level=logging.INFO) + Mailbox.get_new_mail_all_mailboxes(args) diff --git a/django_mailbox/models.py b/django_mailbox/models.py index 42296e9..871b0cc 100644 --- a/django_mailbox/models.py +++ b/django_mailbox/models.py @@ -462,6 +462,27 @@ class Mailbox(models.Model): else: self.save() + @staticmethod + def get_new_mail_all_mailboxes(args=None): + mailboxes = Mailbox.active_mailboxes.all() + if args: + mailboxes = mailboxes.filter( + name=' '.join(args) + ) + for mailbox in mailboxes: + logger.info( + 'Gathering messages for %s', + mailbox.name + ) + messages = mailbox.get_new_mail() + for message in messages: + logger.info( + 'Received %s (from %s)', + message.subject, + message.from_address + ) + + def __str__(self): return self.name