├── version.json ├── requirements.txt ├── requirements-dev.txt ├── pyo365 ├── __version__.py ├── utils │ ├── __init__.py │ ├── utils.py │ ├── attachment.py │ └── windows_tz.py ├── __init__.py ├── account.py ├── mailbox.py ├── address_book.py ├── message.py └── connection.py ├── tests ├── run_tests_notes.txt ├── test_mailbox.py └── test_message.py ├── .gitignore ├── setup.py ├── release.py ├── LICENSE └── README.md /version.json: -------------------------------------------------------------------------------- 1 | {"version": "0.1.3"} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.6.3 2 | python-dateutil==2.7.4 3 | pytz==2018.6 4 | requests==2.20.0 5 | requests-oauthlib==1.0.0 6 | stringcase==1.2.0 7 | tzlocal==1.5.1 8 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.6.3 2 | python-dateutil==2.7.4 3 | pytz==2018.6 4 | requests==2.20.0 5 | requests-oauthlib==1.0.0 6 | stringcase==1.2.0 7 | tzlocal==1.5.1 8 | Click==7.0 9 | pytest==3.9.1 10 | twine==1.12.1 11 | wheel==0.32.1 -------------------------------------------------------------------------------- /pyo365/__version__.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import get_distribution, DistributionNotFound 2 | 3 | 4 | try: 5 | __version__ = get_distribution(__name__.split('.')[0]).version 6 | except DistributionNotFound: 7 | # Package is not installed. 8 | __version__ = None 9 | -------------------------------------------------------------------------------- /pyo365/utils/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .utils import ApiComponent, OutlookWellKnowFolderNames, OneDriveWellKnowFolderNames, \ 3 | Pagination, Query, NEXT_LINK_KEYWORD, ME_RESOURCE, ImportanceLevel, TrackerSet 4 | from .attachment import BaseAttachments, BaseAttachment, AttachableMixin 5 | from .windows_tz import IANA_TO_WIN, WIN_TO_IANA 6 | -------------------------------------------------------------------------------- /tests/run_tests_notes.txt: -------------------------------------------------------------------------------- 1 | To run this tests you will need pytest installed. 2 | 3 | This tests also needs a "config.py" file with two variables: 4 | 5 | CLIENT_ID = 'you client_id' 6 | CLIENT_SECRET = 'your client_secret' 7 | 8 | For oauth to work you will need to include the o365_token.txt file inside the tests folder once it's configured from the standard oauth authorization flow. -------------------------------------------------------------------------------- /pyo365/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple python library to interact with Microsoft Graph and Office 365 API 3 | """ 4 | 5 | from .__version__ import __version__ 6 | 7 | from .account import Account 8 | from .connection import Connection, Protocol, MSGraphProtocol, MSOffice365Protocol, oauth_authentication_flow 9 | from .mailbox import MailBox 10 | from .message import Message, MessageAttachment, Recipient 11 | from .address_book import AddressBook, Contact, RecipientType 12 | from .calendar import Schedule, Calendar, Event, EventResponse, AttendeeType, EventSensitivity, EventShowAs, CalendarColors, EventAttachment 13 | from .drive import Storage, Drive, Folder, File, Image, Photo 14 | from .utils import OneDriveWellKnowFolderNames, OutlookWellKnowFolderNames, ImportanceLevel 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | 56 | # Passwords and other things I care to publish 57 | *.pw 58 | *bookings.json 59 | */pid 60 | 61 | .idea/ 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | 5 | def read(fname): 6 | """ Returns the contents of the fname file """ 7 | with open(os.path.join(os.path.dirname(__file__), fname), 'r') as file: 8 | return file.read() 9 | 10 | 11 | # Available classifiers: https://pypi.org/pypi?%3Aaction=list_classifiers 12 | CLASSIFIERS = [ 13 | 'Development Status :: 4 - Beta', 14 | 'Intended Audience :: Developers', 15 | 'License :: OSI Approved :: Apache Software License', 16 | 'Topic :: Office/Business :: Office Suites', 17 | 'Topic :: Software Development :: Libraries', 18 | 'Programming Language :: Python', 19 | 'Programming Language :: Python :: 3 :: Only', 20 | 'Programming Language :: Python :: 3.4', 21 | 'Programming Language :: Python :: 3.5', 22 | 'Programming Language :: Python :: 3.6', 23 | 'Programming Language :: Python :: 3.7', 24 | 'Programming Language :: Python :: 3.8', 25 | 'Operating System :: OS Independent', 26 | ] 27 | 28 | 29 | requires = [ 30 | 'requests>=2.0.0', 31 | 'requests_oauthlib>=1.0.0', 32 | 'python-dateutil>=2.7', 33 | 'pytz>=2018.5', 34 | 'tzlocal>=1.5.0', 35 | 'beautifulsoup4>=4.0.0', 36 | 'stringcase>=1.2.0' 37 | ] 38 | 39 | setup( 40 | name='pyo365', 41 | version='0.1.3', 42 | packages=find_packages(), 43 | url=' https://github.com/janscas/pyo365', 44 | license='Apache License 2.0', 45 | author='Janscas', 46 | author_email='janscas@users.noreply.github.com', 47 | maintainer='Janscas', 48 | maintainer_email='janscas@users.noreply.github.com', 49 | description='A simple python library to interact with Microsoft Graph and Office 365 API', 50 | long_description=read('README.md'), 51 | long_description_content_type="text/markdown", 52 | classifiers=CLASSIFIERS, 53 | python_requires=">=3.4", 54 | install_requires=requires, 55 | ) 56 | -------------------------------------------------------------------------------- /tests/test_mailbox.py: -------------------------------------------------------------------------------- 1 | from tests.config import CLIENT_ID, CLIENT_SECRET 2 | from pyo365 import Account 3 | 4 | 5 | class TestMailBox: 6 | 7 | def setup_class(self): 8 | credentials = (CLIENT_ID, CLIENT_SECRET) 9 | self.account = Account(credentials) 10 | self.mailbox = self.account.mailbox() 11 | self.folder_name = 'Test Drafts Subfolder' 12 | 13 | def teardown_class(self): 14 | pass 15 | 16 | def test_get_mailbox_folders(self): 17 | folders = self.mailbox.get_folders(limit=5) 18 | 19 | assert len(folders) > 0 20 | 21 | def test_create_child_folder(self): 22 | drafts = self.mailbox.drafts_folder() 23 | 24 | new_folder = drafts.create_child_folder(self.folder_name) 25 | 26 | assert new_folder is not None 27 | 28 | def test_get_folder_by_name(self): 29 | drafts = self.mailbox.drafts_folder() 30 | 31 | q = self.mailbox.q('display_name').equals(self.folder_name) 32 | 33 | folder = drafts.get_folder(folder_name=self.folder_name) 34 | 35 | assert folder is not None 36 | 37 | def test_get_parent_folder(self): 38 | new_folder = self.mailbox.drafts_folder().get_folder(folder_name=self.folder_name) 39 | 40 | if new_folder: 41 | parent_folder = new_folder.get_parent_folder() 42 | 43 | assert new_folder and parent_folder is not None 44 | 45 | def test_get_child_folders(self): 46 | new_folder = self.mailbox.drafts_folder().get_folder(folder_name=self.folder_name) 47 | 48 | if new_folder: 49 | parent_folder = new_folder.get_parent_folder() 50 | child_folders = parent_folder.get_folders(limit=2) 51 | 52 | assert new_folder and parent_folder and len(child_folders) >= 1 and any(folder.name == self.folder_name for folder in child_folders) 53 | 54 | def test_move_folder(self): 55 | new_folder = self.mailbox.drafts_folder().get_folder(folder_name=self.folder_name) 56 | sent_folder = self.mailbox.sent_folder() 57 | if new_folder: 58 | moved = new_folder.move_folder(sent_folder) 59 | 60 | assert new_folder and moved 61 | 62 | def test_copy_folder(self): 63 | new_folder = self.mailbox.sent_folder().get_folder(folder_name=self.folder_name) # new_folder is in sent folder now 64 | drafts_folder = self.mailbox.drafts_folder() 65 | 66 | if new_folder: 67 | copied_folder = new_folder.copy_folder(drafts_folder) 68 | deleted = copied_folder.delete() # delete this copy early on 69 | 70 | assert new_folder and copied_folder is not None and deleted 71 | 72 | def test_refresh_folder(self): 73 | # new_folder = self.mailbox.sent_folder().get_folder(folder_name=self.folder_name) # new_folder is in sent folder now 74 | 75 | sent_folder = self.mailbox.sent_folder() 76 | 77 | old_id = sent_folder.folder_id 78 | refreshed = sent_folder.refresh_folder() 79 | new_id = sent_folder.folder_id 80 | 81 | assert refreshed and old_id != new_id 82 | 83 | def test_update_folder_name(self): 84 | new_folder = self.mailbox.sent_folder().get_folder(folder_name=self.folder_name) # new_folder is in sent folder now 85 | 86 | if new_folder: 87 | old_name = new_folder.name 88 | updated = new_folder.update_folder_name(self.folder_name + ' new name!') 89 | 90 | assert new_folder and updated and old_name != new_folder.name 91 | 92 | def test_delete_folder(self): 93 | new_folder = self.mailbox.sent_folder().get_folder(folder_name=self.folder_name) # new_folder is in sent folder now 94 | 95 | if new_folder: 96 | deleted = new_folder.delete() 97 | 98 | assert new_folder and deleted 99 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | """ 2 | Release script 3 | """ 4 | 5 | import os 6 | import shutil 7 | import subprocess 8 | import sys 9 | import requests 10 | from pathlib import Path 11 | 12 | # noinspection PyPackageRequirements 13 | import click 14 | 15 | 16 | PYPI_PACKAGE_NAME = 'pyo365' 17 | PYPI_URL = 'https://pypi.org/pypi/{package}/json' 18 | DIST_PATH = 'dist' 19 | DIST_PATH_DELETE = 'dist_delete' 20 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 21 | 22 | 23 | @click.group(context_settings=CONTEXT_SETTINGS) 24 | def cli(): 25 | pass 26 | 27 | 28 | @cli.command() 29 | @click.option('--force/--no-force', default=False, help='Will force a new build removing the previous ones') 30 | def build(force): 31 | """ Builds the distribution files: wheels and source. """ 32 | dist_path = Path(DIST_PATH) 33 | if dist_path.exists() and list(dist_path.glob('*')): 34 | if force or click.confirm('{} is not empty - delete contents?'.format(dist_path)): 35 | dist_path.rename(DIST_PATH_DELETE) 36 | shutil.rmtree(Path(DIST_PATH_DELETE)) 37 | dist_path.mkdir() 38 | else: 39 | click.echo('Aborting') 40 | sys.exit(1) 41 | 42 | subprocess.check_call(['python', 'setup.py', 'bdist_wheel']) 43 | subprocess.check_call(['python', 'setup.py', 'sdist', 44 | '--formats=gztar']) 45 | 46 | 47 | @cli.command() 48 | @click.option('--release/--no-release', default=False, help='--release to upload to pypi otherwise upload to test.pypi') 49 | @click.option('--rebuild/--no-rebuild', default=True, help='Will force a rebuild of the build files (src and wheels)') 50 | @click.pass_context 51 | def upload(ctx, release, rebuild): 52 | """ Uploads distribuition files to pypi or pypitest. """ 53 | 54 | dist_path = Path(DIST_PATH) 55 | if rebuild is False: 56 | if not dist_path.exists() or not list(dist_path.glob('*')): 57 | print("No distribution files found. Please run 'build' command first") 58 | return 59 | else: 60 | ctx.invoke(build, force=True) 61 | 62 | if release: 63 | args = ['twine', 'upload', 'dist/*'] 64 | else: 65 | repository = 'https://test.pypi.org/legacy/' 66 | args = ['twine', 'upload', '--repository-url', repository, 'dist/*'] 67 | 68 | env = os.environ.copy() 69 | 70 | p = subprocess.Popen(args, env=env) 71 | p.wait() 72 | 73 | 74 | @cli.command() 75 | def check(): 76 | """ Checks the long description. """ 77 | dist_path = Path(DIST_PATH) 78 | if not dist_path.exists() or not list(dist_path.glob('*')): 79 | print("No distribution files found. Please run 'build' command first") 80 | return 81 | 82 | subprocess.check_call(['twine', 'check', 'dist/*']) 83 | 84 | 85 | # noinspection PyShadowingBuiltins 86 | @cli.command(name='list') 87 | def list_releases(): 88 | """ Lists all releases published on pypi""" 89 | response = requests.get(PYPI_URL.format(package=PYPI_PACKAGE_NAME)) 90 | if response: 91 | data = response.json() 92 | 93 | releases_dict = data.get('releases', {}) 94 | 95 | if releases_dict: 96 | for version, release in releases_dict.items(): 97 | release_formats = [] 98 | published_on_date = None 99 | for fmt in release: 100 | release_formats.append(fmt.get('packagetype')) 101 | published_on_date = fmt.get('upload_time') 102 | 103 | release_formats = ' | '.join(release_formats) 104 | print('{:<10}{:>15}{:>25}'.format(version, published_on_date, release_formats)) 105 | else: 106 | print('No releases found for {}'.format(PYPI_PACKAGE_NAME)) 107 | else: 108 | print('Package "{}" not found on Pypi.org'.format(PYPI_PACKAGE_NAME)) 109 | 110 | 111 | if __name__ == "__main__": 112 | cli() 113 | -------------------------------------------------------------------------------- /pyo365/account.py: -------------------------------------------------------------------------------- 1 | from pyo365.connection import Connection, Protocol, MSGraphProtocol, oauth_authentication_flow 2 | from pyo365.drive import Storage 3 | from pyo365.utils import ME_RESOURCE 4 | from pyo365.message import Message 5 | from pyo365.mailbox import MailBox 6 | from pyo365.address_book import AddressBook, GlobalAddressList 7 | from pyo365.calendar import Schedule 8 | 9 | 10 | class Account(object): 11 | """ Class helper to integrate all components into a single object """ 12 | 13 | def __init__(self, credentials, *, protocol=None, main_resource=ME_RESOURCE, **kwargs): 14 | """ 15 | Account constructor. 16 | :param credentials: a tuple containing the client_id and client_secret 17 | :param protocol: the protocol to be used in this account instance 18 | :param main_resource: the resource to be used by this account 19 | :param kwargs: any extra args to be passed to the Connection instance 20 | """ 21 | protocol = protocol or MSGraphProtocol # defaults to Graph protocol 22 | self.protocol = protocol(default_resource=main_resource, **kwargs) if isinstance(protocol, type) else protocol 23 | 24 | if not isinstance(self.protocol, Protocol): 25 | raise ValueError("'protocol' must be a subclass of Protocol") 26 | 27 | self.con = Connection(credentials, **kwargs) 28 | self.main_resource = main_resource 29 | 30 | def __repr__(self): 31 | if self.con.auth: 32 | return 'Account Client Id: {}'.format(self.con.auth[0]) 33 | else: 34 | return 'Unidentified Account' 35 | 36 | def authenticate(self, *, scopes, **kwargs): 37 | """ 38 | Performs the oauth authentication flow resulting in a stored token. 39 | It uses the credentials passed on instantiation 40 | :param scopes: a list of protocol user scopes to be converted by the protocol 41 | :param kwargs: other configuration to be passed to the Connection instance 42 | """ 43 | kwargs.setdefault('token_file_name', self.con.token_path.name) 44 | 45 | return oauth_authentication_flow(*self.con.auth, scopes=scopes, protocol=self.protocol, **kwargs) 46 | 47 | @property 48 | def connection(self): 49 | """ Alias for self.con """ 50 | return self.con 51 | 52 | def new_message(self, resource=None): 53 | """ 54 | Creates a new message to be send or stored 55 | :param resource: Custom resource to be used in this message. Defaults to parent main_resource. 56 | """ 57 | return Message(parent=self, main_resource=resource, is_draft=True) 58 | 59 | def mailbox(self, resource=None): 60 | """ 61 | Creates MailBox Folder instance 62 | :param resource: Custom resource to be used in this mailbox. Defaults to parent main_resource. 63 | """ 64 | return MailBox(parent=self, main_resource=resource, name='MailBox') 65 | 66 | def address_book(self, *, resource=None, address_book='personal'): 67 | """ 68 | Creates Address Book instance 69 | :param resource: Custom resource to be used in this address book. Defaults to parent main_resource. 70 | :param address_book: Choose from Personal or Gal (Global Address List) 71 | """ 72 | if address_book == 'personal': 73 | return AddressBook(parent=self, main_resource=resource, name='Personal Address Book') 74 | elif address_book == 'gal': 75 | return GlobalAddressList(parent=self) 76 | else: 77 | raise RuntimeError('Addres_book must be either "personal" (resource address book) or "gal" (Global Address List)') 78 | 79 | def schedule(self, *, resource=None): 80 | """ 81 | Creates Schedule instance to handle calendars 82 | :param resource: Custom resource to be used in this schedule object. Defaults to parent main_resource. 83 | """ 84 | return Schedule(parent=self, main_resource=resource) 85 | 86 | def storage(self, *, resource=None): 87 | """ 88 | Creates a Storage instance to handle file storage like OneDrive or Sharepoint document libraries 89 | :param resource: Custom resource to be used in this drive object. Defaults to parent main_resource. 90 | """ 91 | if not isinstance(self.protocol, MSGraphProtocol): 92 | # TODO: a custom protocol accessing OneDrive or Sharepoint Api will fail here. 93 | raise RuntimeError('Drive options only works on Microsoft Graph API') 94 | 95 | return Storage(parent=self, main_resource=resource) 96 | -------------------------------------------------------------------------------- /tests/test_message.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from tests.config import CLIENT_ID, CLIENT_SECRET 3 | from pyo365 import Account 4 | 5 | 6 | class TestMessage: 7 | 8 | def setup_class(self): 9 | credentials = (CLIENT_ID, CLIENT_SECRET) 10 | self.account = Account(credentials) 11 | self.mailbox = self.account.mailbox() 12 | self.inbox = self.mailbox.inbox_folder() 13 | self.drafts = self.mailbox.drafts_folder() 14 | self.test_msg_subject1 = 'Test Msg 1548lop102' 15 | self.test_msg_subject2 = 'Test Msg 1548lop103' 16 | 17 | def teardown_class(self): 18 | pass 19 | 20 | def test_get_inbox_mails(self): 21 | messages = self.inbox.get_messages(5) 22 | 23 | assert len(messages) != 0 24 | 25 | def test_new_email_draft(self): 26 | msg = self.account.new_message() 27 | msg.subject = self.test_msg_subject1 28 | msg.body = 'A message test' 29 | msg.save_draft() 30 | 31 | message = self.drafts.get_message(self.drafts.q('subject').equals(self.test_msg_subject1)) 32 | 33 | assert message is not None 34 | 35 | def test_update_email(self): 36 | q = self.drafts.q('subject').equals(self.test_msg_subject1) 37 | 38 | message = self.drafts.get_message(q) 39 | message2 = None 40 | 41 | if message: 42 | message.to.add('test@example.com') 43 | saved = message.save_draft() 44 | 45 | message2 = self.drafts.get_message(q) 46 | 47 | assert message and saved and message2 and message2.to and message2.to[0].address == 'test@example.com' 48 | 49 | def test_add_attachment(self): 50 | q = self.drafts.q('subject').equals(self.test_msg_subject1) 51 | 52 | message = self.drafts.get_message(q) 53 | message2 = None 54 | 55 | if message: 56 | dummy_file = Path('dummy.txt') 57 | with dummy_file.open(mode='w') as file: 58 | file.write('Test file') 59 | message.attachments.add(dummy_file) # add this file as an attachment 60 | saved = message.save_draft() 61 | dummy_file.unlink() # delete dummy file 62 | 63 | message2 = self.drafts.get_message(q) 64 | 65 | assert message and saved and message2 and message2.has_attachments 66 | 67 | def test_remove_attachment(self): 68 | q = self.drafts.q('subject').equals(self.test_msg_subject1) 69 | 70 | message = self.drafts.get_message(q, download_attachments=True) 71 | message2 = None 72 | 73 | if message: 74 | message.attachments.clear() 75 | saved = message.save_draft() 76 | 77 | message2 = self.drafts.get_message(q) 78 | 79 | assert message and saved and message2 and not message2.has_attachments 80 | 81 | def test_delete_email(self): 82 | q = self.drafts.q('subject').equals(self.test_msg_subject1) 83 | 84 | message = self.drafts.get_message(q) 85 | 86 | if message: 87 | deleted = message.delete() 88 | 89 | message = self.drafts.get_message(q) 90 | 91 | assert deleted and message is None 92 | 93 | def test_reply(self): 94 | message = self.inbox.get_message() # get first message in inbox 95 | 96 | reply = None 97 | reply_text = 'New reply on top of the message trail.' 98 | 99 | if message: 100 | reply = message.reply() 101 | reply.body = reply_text 102 | reply.subject = self.test_msg_subject2 103 | saved = reply.save_draft() 104 | 105 | assert message and reply and reply.body != reply_text and saved 106 | 107 | def test_move_to_folder(self): 108 | q = self.mailbox.q('subject').equals(self.test_msg_subject2) 109 | 110 | message = self.drafts.get_message(q) 111 | 112 | deleted_folder = self.mailbox.deleted_folder() 113 | if message: 114 | moved = message.move(deleted_folder) 115 | 116 | message = deleted_folder.get_message(q) 117 | 118 | assert message and moved and message 119 | 120 | def test_copy_message(self): 121 | q = self.mailbox.q('subject').equals(self.test_msg_subject2) 122 | 123 | deleted_folder = self.mailbox.deleted_folder() 124 | message = deleted_folder.get_message(q) 125 | 126 | if message: 127 | copied = message.copy(self.drafts) 128 | deleted = copied.delete() 129 | 130 | assert message and copied and deleted 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /pyo365/utils/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | import datetime as dt 4 | import pytz 5 | from collections import OrderedDict 6 | 7 | ME_RESOURCE = 'me' 8 | USERS_RESOURCE = 'users' 9 | 10 | NEXT_LINK_KEYWORD = '@odata.nextLink' 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | MAX_RECIPIENTS_PER_MESSAGE = 500 # Actual limit on Office 365 15 | 16 | 17 | class ImportanceLevel(Enum): 18 | Normal = 'normal' 19 | Low = 'low' 20 | High = 'high' 21 | 22 | 23 | class OutlookWellKnowFolderNames(Enum): 24 | INBOX = 'Inbox' 25 | JUNK = 'JunkEmail' 26 | DELETED = 'DeletedItems' 27 | DRAFTS = 'Drafts' 28 | SENT = 'SentItems' 29 | OUTBOX = 'Outbox' 30 | 31 | 32 | class OneDriveWellKnowFolderNames(Enum): 33 | DOCUMENTS = 'documents' 34 | PHOTOS = 'photos' 35 | CAMERA_ROLL = 'cameraroll' 36 | APP_ROOT = 'approot' 37 | MUSIC = 'music' 38 | ATTACHMENTS = 'attachments' 39 | 40 | 41 | class ChainOperator(Enum): 42 | AND = 'and' 43 | OR = 'or' 44 | 45 | 46 | class TrackerSet(set): 47 | """ A Custom Set that changes the casing of it's keys """ 48 | 49 | def __init__(self, *args, casing=None, **kwargs): 50 | self.cc = casing 51 | super().__init__(*args, **kwargs) 52 | 53 | def add(self, value): 54 | value = self.cc(value) 55 | super().add(value) 56 | 57 | 58 | class ApiComponent: 59 | """ Base class for all object interactions with the Cloud Service API 60 | 61 | Exposes common access methods to the api protocol within all Api objects 62 | """ 63 | 64 | _cloud_data_key = '__cloud_data__' # wrapps cloud data with this dict key 65 | _endpoints = {} # dict of all API service endpoints needed 66 | 67 | def __init__(self, *, protocol=None, main_resource=None, **kwargs): 68 | """ Object initialization 69 | :param protocol: A protocol class or instance to be used with this connection 70 | :param main_resource: main_resource to be used in these API comunications 71 | :param kwargs: Extra arguments 72 | """ 73 | self.protocol = protocol() if isinstance(protocol, type) else protocol 74 | if self.protocol is None: 75 | raise ValueError('Protocol not provided to Api Component') 76 | self.main_resource = self._parse_resource(main_resource or protocol.default_resource) 77 | self._base_url = '{}{}'.format(self.protocol.service_url, self.main_resource) 78 | super().__init__() 79 | 80 | @staticmethod 81 | def _parse_resource(resource): 82 | """ Parses and completes resource information """ 83 | resource = resource.strip() if resource else resource 84 | 85 | if resource == ME_RESOURCE: 86 | return resource 87 | elif resource == USERS_RESOURCE: 88 | return resource 89 | elif '/' not in resource and USERS_RESOURCE not in resource: 90 | # when for example accesing a shared mailbox the resouse is set to the email address. 91 | # we have to prefix the email with the resource 'users/' so --> 'users/email_address' 92 | return '{}/{}'.format(USERS_RESOURCE, resource) 93 | else: 94 | return resource 95 | 96 | def build_url(self, endpoint): 97 | """ Returns a url for a given endpoint using the protocol service url """ 98 | return '{}{}'.format(self._base_url, endpoint) 99 | 100 | def _gk(self, keyword): 101 | """ Alias for protocol.get_service_keyword """ 102 | return self.protocol.get_service_keyword(keyword) 103 | 104 | def _cc(self, dict_key): 105 | """ Alias for protocol.convert_case """ 106 | return self.protocol.convert_case(dict_key) 107 | 108 | def new_query(self, attribute=None): 109 | return Query(attribute=attribute, protocol=self.protocol) 110 | 111 | q = new_query # alias for new query 112 | 113 | 114 | class Pagination(ApiComponent): 115 | """ Utility class that allows batching requests to the server """ 116 | 117 | def __init__(self, *, parent=None, data=None, constructor=None, next_link=None, limit=None): 118 | """ 119 | Returns an iterator that returns data until it's exhausted. Then will request more data 120 | (same amount as the original request) to the server until this data is exhausted as well. 121 | Stops when no more data exists or limit is reached. 122 | 123 | :param parent: the parent class. Must implement attributes: 124 | con, api_version, main_resource 125 | :param data: the start data to be return 126 | :param constructor: the data constructor for the next batch. It can be a function. 127 | :param next_link: the link to request more data to 128 | :param limit: when to stop retrieving more data 129 | """ 130 | if parent is None: 131 | raise ValueError('Parent must be another Api Component') 132 | 133 | super().__init__(protocol=parent.protocol, main_resource=parent.main_resource) 134 | 135 | self.parent = parent 136 | self.con = parent.con 137 | self.constructor = constructor 138 | self.next_link = next_link 139 | self.limit = limit 140 | self.data = data if data else [] 141 | 142 | data_count = len(data) 143 | if limit and limit < data_count: 144 | self.data_count = limit 145 | self.total_count = limit 146 | else: 147 | self.data_count = data_count 148 | self.total_count = data_count 149 | self.state = 0 150 | 151 | def __str__(self): 152 | return self.__repr__() 153 | 154 | def __repr__(self): 155 | if callable(self.constructor): 156 | return 'Pagination Iterator' 157 | else: 158 | return "'{}' Iterator".format(self.constructor.__name__ if self.constructor else 'Unknown') 159 | 160 | def __bool__(self): 161 | return bool(self.data) or bool(self.next_link) 162 | 163 | def __iter__(self): 164 | return self 165 | 166 | def __next__(self): 167 | if self.state < self.data_count: 168 | value = self.data[self.state] 169 | self.state += 1 170 | return value 171 | else: 172 | if self.limit and self.total_count >= self.limit: 173 | raise StopIteration() 174 | 175 | if self.next_link is None: 176 | raise StopIteration() 177 | 178 | response = self.con.get(self.next_link) 179 | if not response: 180 | raise StopIteration() 181 | 182 | data = response.json() 183 | 184 | self.next_link = data.get(NEXT_LINK_KEYWORD, None) or None 185 | data = data.get('value', []) 186 | if self.constructor: 187 | # Everything received from the cloud must be passed with self._cloud_data_key 188 | if callable(self.constructor) and not isinstance(self.constructor, type): 189 | self.data = [self.constructor(value)(parent=self.parent, **{self._cloud_data_key: value}) for value in data] 190 | else: 191 | self.data = [self.constructor(parent=self.parent, **{self._cloud_data_key: value}) for value in data] 192 | else: 193 | self.data = data 194 | 195 | items_count = len(data) 196 | if self.limit: 197 | dif = self.limit - (self.total_count + items_count) 198 | if dif < 0: 199 | self.data = self.data[:dif] 200 | self.next_link = None # stop batching 201 | items_count = items_count + dif 202 | if items_count: 203 | self.data_count = items_count 204 | self.total_count += items_count 205 | self.state = 0 206 | value = self.data[self.state] 207 | self.state += 1 208 | return value 209 | else: 210 | raise StopIteration() 211 | 212 | 213 | class Query: 214 | """ Helper to conform OData filters """ 215 | _mapping = { 216 | 'from': 'from/emailAddress/address', 217 | 'to': 'toRecipients/emailAddress/address', 218 | 'start': 'start/DateTime', 219 | 'end': 'end/DateTime' 220 | } 221 | 222 | def __init__(self, attribute=None, *, protocol): 223 | self.protocol = protocol() if isinstance(protocol, type) else protocol 224 | self._attribute = None 225 | self._chain = None 226 | self.new(attribute) 227 | self._negation = False 228 | self._filters = [] 229 | self._order_by = OrderedDict() 230 | self._selects = set() 231 | 232 | def __str__(self): 233 | return 'Filter: {}\nOrder: {}\nSelect: {}'.format(self.get_filters(), self.get_order(), self.get_selects()) 234 | 235 | def __repr__(self): 236 | return self.__str__() 237 | 238 | def select(self, *attributes): 239 | """ 240 | Adds the attribute to the $select parameter 241 | :param attributes: the attributes tuple to select. If empty, the on_attribute previously set is added. 242 | """ 243 | if attributes: 244 | for attribute in attributes: 245 | attribute = self.protocol.convert_case(attribute) if attribute and isinstance(attribute, str) else None 246 | if attribute: 247 | if '/' in attribute: 248 | # only parent attribute can be selected 249 | attribute = attribute.split('/')[0] 250 | self._selects.add(attribute) 251 | else: 252 | if self._attribute: 253 | self._selects.add(self._attribute) 254 | 255 | return self 256 | 257 | def as_params(self): 258 | """ Returns the filters and orders as query parameters""" 259 | params = {} 260 | if self.has_filters: 261 | params['$filter'] = self.get_filters() 262 | if self.has_order: 263 | params['$orderby'] = self.get_order() 264 | if self.has_selects: 265 | params['$select'] = self.get_selects() 266 | return params 267 | 268 | @property 269 | def has_filters(self): 270 | return bool(self._filters) 271 | 272 | @property 273 | def has_order(self): 274 | return bool(self._order_by) 275 | 276 | @property 277 | def has_selects(self): 278 | return bool(self._selects) 279 | 280 | def get_filters(self): 281 | """ Returns the result filters """ 282 | if self._filters: 283 | filters_list = self._filters 284 | if isinstance(filters_list[-1], Enum): 285 | filters_list = filters_list[:-1] 286 | return ' '.join([fs.value if isinstance(fs, Enum) else fs[1] for fs in filters_list]).strip() 287 | else: 288 | return None 289 | 290 | def get_order(self): 291 | """ Returns the result order by clauses """ 292 | # first get the filtered attributes in order as they must appear in the order_by first 293 | if not self.has_order: 294 | return None 295 | filter_order_clauses = OrderedDict([(filter_attr[0], None) 296 | for filter_attr in self._filters 297 | if isinstance(filter_attr, tuple)]) 298 | 299 | # any order_by attribute that appears in the filters is ignored 300 | order_by_dict = self._order_by.copy() 301 | for filter_oc in filter_order_clauses.keys(): 302 | direction = order_by_dict.pop(filter_oc, None) 303 | filter_order_clauses[filter_oc] = direction 304 | 305 | filter_order_clauses.update(order_by_dict) # append any remaining order_by clause 306 | 307 | if filter_order_clauses: 308 | return ','.join(['{} {}'.format(attribute, direction if direction else '').strip() 309 | for attribute, direction in filter_order_clauses.items()]) 310 | else: 311 | return None 312 | 313 | def get_selects(self): 314 | """ Returns the result select clause """ 315 | if self._selects: 316 | return ','.join(self._selects) 317 | else: 318 | return None 319 | 320 | def _get_mapping(self, attribute): 321 | if attribute: 322 | mapping = self._mapping.get(attribute) 323 | if mapping: 324 | attribute = '/'.join([self.protocol.convert_case(step) for step in mapping.split('/')]) 325 | else: 326 | attribute = self.protocol.convert_case(attribute) 327 | return attribute 328 | return None 329 | 330 | def new(self, attribute, operation=ChainOperator.AND): 331 | if isinstance(operation, str): 332 | operation = ChainOperator(operation) 333 | self._chain = operation 334 | self._attribute = self._get_mapping(attribute) if attribute else None 335 | self._negation = False 336 | return self 337 | 338 | def clear_filters(self): 339 | self._filters = [] 340 | 341 | def clear(self): 342 | self._filters = [] 343 | self._order_by = OrderedDict() 344 | self._selects = set() 345 | self.new(None) 346 | return self 347 | 348 | def negate(self): 349 | self._negation = not self._negation 350 | return self 351 | 352 | def chain(self, operation=ChainOperator.AND): 353 | if isinstance(operation, str): 354 | operation = ChainOperator(operation) 355 | self._chain = operation 356 | return self 357 | 358 | def on_attribute(self, attribute): 359 | self._attribute = self._get_mapping(attribute) 360 | return self 361 | 362 | def _add_filter(self, filter_str): 363 | if self._attribute: 364 | if self._filters and not isinstance(self._filters[-1], ChainOperator): 365 | self._filters.append(self._chain) 366 | self._filters.append((self._attribute, filter_str)) 367 | else: 368 | raise ValueError('Attribute property needed. call on_attribute(attribute) or new(attribute)') 369 | 370 | def _parse_filter_word(self, word): 371 | """ Converts the word parameter into the correct format """ 372 | if isinstance(word, str): 373 | word = "'{}'".format(word) 374 | elif isinstance(word, dt.date): 375 | if isinstance(word, dt.datetime): 376 | if word.tzinfo is None: 377 | # if it's a naive datetime, localize the datetime. 378 | word = self.protocol.timezone.localize(word) # localize datetime into local tz 379 | if word.tzinfo != pytz.utc: 380 | word = word.astimezone(pytz.utc) # transform local datetime to utc 381 | word = '{}'.format(word.isoformat()) # convert datetime to isoformat 382 | elif isinstance(word, bool): 383 | word = str(word).lower() 384 | return word 385 | 386 | def logical_operator(self, operation, word): 387 | word = self._parse_filter_word(word) 388 | sentence = '{} {} {} {}'.format('not' if self._negation else '', self._attribute, operation, word).strip() 389 | self._add_filter(sentence) 390 | return self 391 | 392 | def equals(self, word): 393 | return self.logical_operator('eq', word) 394 | 395 | def unequal(self, word): 396 | return self.logical_operator('ne', word) 397 | 398 | def greater(self, word): 399 | return self.logical_operator('gt', word) 400 | 401 | def greater_equal(self, word): 402 | return self.logical_operator('ge', word) 403 | 404 | def less(self, word): 405 | return self.logical_operator('lt', word) 406 | 407 | def less_equal(self, word): 408 | return self.logical_operator('le', word) 409 | 410 | def function(self, function_name, word): 411 | word = self._parse_filter_word(word) 412 | 413 | self._add_filter( 414 | "{} {}({}, {})".format('not' if self._negation else '', function_name, self._attribute, word).strip()) 415 | return self 416 | 417 | def contains(self, word): 418 | return self.function('contains', word) 419 | 420 | def startswith(self, word): 421 | return self.function('startswith', word) 422 | 423 | def endswith(self, word): 424 | return self.function('endswith', word) 425 | 426 | def order_by(self, attribute=None, *, ascending=True): 427 | """ applies a order_by clause""" 428 | attribute = self._get_mapping(attribute) or self._attribute 429 | if attribute: 430 | self._order_by[attribute] = None if ascending else 'desc' 431 | else: 432 | raise ValueError('Attribute property needed. call on_attribute(attribute) or new(attribute)') 433 | return self 434 | -------------------------------------------------------------------------------- /pyo365/utils/attachment.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import base64 3 | from pathlib import Path 4 | 5 | from pyo365.utils.utils import ApiComponent 6 | 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | class AttachableMixin: 12 | """ 13 | Defines the functionality for an object to be attachable. 14 | Any object that inherits from this class will be attachable (if the underlying api allows that) 15 | """ 16 | 17 | def __init__(self, attachment_name_property=None, attachment_type=None): 18 | self.__attachment_name = None 19 | self.__attachment_name_property = attachment_name_property 20 | self.__attachment_type = self._gk(attachment_type) 21 | 22 | @property 23 | def attachment_name(self): 24 | if self.__attachment_name is not None: 25 | return self.__attachment_name 26 | if self.__attachment_name_property: 27 | return getattr(self, self.__attachment_name_property, '') 28 | else: 29 | # property order resolution: 30 | # 1) try property 'subject' 31 | # 2) try property 'name' 32 | try: 33 | attachment_name = getattr(self, 'subject') 34 | except AttributeError: 35 | attachment_name = getattr(self, 'name', '') 36 | return attachment_name 37 | 38 | @attachment_name.setter 39 | def attachment_name(self, value): 40 | self.__attachment_name = value 41 | 42 | @property 43 | def attachment_type(self): 44 | return self.__attachment_type 45 | 46 | def to_api_data(self): 47 | raise NotImplementedError() 48 | 49 | 50 | class BaseAttachment(ApiComponent): 51 | """ BaseAttachment class is the base object for dealing with attachments """ 52 | 53 | _endpoints = {'attach': '/messages/{id}/attachments'} 54 | 55 | def __init__(self, attachment=None, *, parent=None, **kwargs): 56 | """ 57 | Creates a new attachment class, optionally from existing cloud data. 58 | 59 | :param attachment: attachment data (dict = cloud data, other = user data) 60 | :param parent: the parent Attachments 61 | :param kwargs: extra options: 62 | - 'protocol' when using attachment standalone 63 | - 'main_resource' when using attachment standalone and is not the default_resource of protocol 64 | """ 65 | kwargs.setdefault('protocol', getattr(parent, 'protocol', None)) 66 | kwargs.setdefault('main_resource', getattr(parent, 'main_resource', None)) 67 | 68 | super().__init__(**kwargs) 69 | self.name = None 70 | self.attachment_type = 'file' 71 | self.attachment_id = None 72 | self.attachment = None 73 | self.content = None 74 | self.on_disk = False 75 | self.on_cloud = kwargs.get('on_cloud', False) 76 | 77 | if attachment: 78 | if isinstance(attachment, dict): 79 | if self._cloud_data_key in attachment: 80 | # data from the cloud 81 | attachment = attachment.get(self._cloud_data_key) 82 | self.attachment_id = attachment.get(self._cc('id'), None) 83 | self.name = attachment.get(self._cc('name'), None) 84 | self.content = attachment.get(self._cc('contentBytes'), None) 85 | self.attachment_type = 'item' if 'item' in attachment.get('@odata.type', '').lower() else 'file' 86 | self.on_disk = False 87 | else: 88 | file_path = attachment.get('path', attachment.get('name')) 89 | if file_path is None: 90 | raise ValueError('Must provide a valid "path" or "name" for the attachment') 91 | self.content = attachment.get('content') 92 | self.on_disk = attachment.get('on_disk') 93 | self.attachment_id = attachment.get('attachment_id') 94 | self.attachment = Path(file_path) if self.on_disk else None 95 | self.name = self.attachment.name if self.on_disk else attachment.get('name') 96 | elif isinstance(attachment, str): 97 | self.attachment = Path(attachment) 98 | self.name = self.attachment.name 99 | elif isinstance(attachment, Path): 100 | self.attachment = attachment 101 | self.name = self.attachment.name 102 | elif isinstance(attachment, (tuple, list)): 103 | file_path, custom_name = attachment 104 | self.attachment = Path(file_path) 105 | self.name = custom_name 106 | elif isinstance(attachment, AttachableMixin): 107 | # Object that can be attached (Message for example) 108 | self.attachment_type = 'item' 109 | self.attachment = attachment 110 | self.name = attachment.attachment_name 111 | self.content = attachment.to_api_data() 112 | self.content['@odata.type'] = attachment.attachment_type 113 | 114 | if self.content is None and self.attachment and self.attachment.exists(): 115 | with self.attachment.open('rb') as file: 116 | self.content = base64.b64encode(file.read()).decode('utf-8') 117 | self.on_disk = True 118 | 119 | def to_api_data(self): 120 | data = {'@odata.type': self._gk('{}_attachment_type'.format(self.attachment_type)), self._cc('name'): self.name} 121 | 122 | if self.attachment_type == 'file': 123 | data[self._cc('contentBytes')] = self.content 124 | else: 125 | data[self._cc('item')] = self.content 126 | 127 | return data 128 | 129 | def save(self, location=None, custom_name=None): 130 | """ Save the attachment locally to disk. 131 | :param location: path string to where the file is to be saved. 132 | :param custom_name: a custom name to be saved as 133 | """ 134 | if not self.content: 135 | return False 136 | 137 | location = Path(location or '') 138 | if not location.exists(): 139 | log.debug('the location provided does not exist') 140 | return False 141 | 142 | name = custom_name or self.name 143 | name = name.replace('/', '-').replace('\\', '') 144 | try: 145 | path = location / name 146 | with path.open('wb') as file: 147 | file.write(base64.b64decode(self.content)) 148 | self.attachment = path 149 | self.on_disk = True 150 | log.debug('file saved locally.') 151 | except Exception as e: 152 | log.error('file failed to be saved: %s', str(e)) 153 | return False 154 | return True 155 | 156 | def attach(self, api_object, on_cloud=False): 157 | """ 158 | Attach this attachment to an existing api_object 159 | This BaseAttachment object must be an orphan BaseAttachment created for the 160 | sole purpose of attach it to something and therefore run this method. 161 | """ 162 | 163 | if self.on_cloud: 164 | # item is already saved on the cloud. 165 | return True 166 | 167 | # api_object must exist and if implements attachments then we can attach to it. 168 | if api_object and getattr(api_object, 'attachments', None): 169 | if on_cloud: 170 | if not api_object.object_id: 171 | raise RuntimeError('A valid object id is needed in order to attach a file') 172 | # api_object builds its own url using its resource and main configuration 173 | url = api_object.build_url(self._endpoints.get('attach').format(id=api_object.object_id)) 174 | 175 | response = api_object.con.post(url, data=self.to_api_data()) 176 | 177 | return bool(response) 178 | else: 179 | if self.attachment_type == 'file': 180 | api_object.attachments.add([{ 181 | 'attachment_id': self.attachment_id, # TODO: copy attachment id? or set to None? 182 | 'path': str(self.attachment) if self.attachment else None, 183 | 'name': self.name, 184 | 'content': self.content, 185 | 'on_disk': self.on_disk 186 | }]) 187 | else: 188 | raise RuntimeError('Only file attachments can be attached') 189 | 190 | def __str__(self): 191 | return self.__repr__() 192 | 193 | def __repr__(self): 194 | return 'Attachment: {}'.format(self.name) 195 | 196 | 197 | class BaseAttachments(ApiComponent): 198 | """ A Collection of BaseAttachments """ 199 | 200 | _endpoints = { 201 | 'attachments': '/messages/{id}/attachments', 202 | 'attachment': '/messages/{id}/attachments/{ida}' 203 | } 204 | _attachment_constructor = BaseAttachment 205 | 206 | def __init__(self, parent, attachments=None): 207 | """ Attachments must be a list of path strings or dictionary elements """ 208 | super().__init__(protocol=parent.protocol, main_resource=parent.main_resource) 209 | self._parent = parent 210 | self.__attachments = [] 211 | self.__removed_attachments = [] # holds on_cloud attachments removed from the parent object 212 | self.untrack = True 213 | if attachments: 214 | self.add(attachments) 215 | self.untrack = False 216 | 217 | def __iter__(self): 218 | return iter(self.__attachments) 219 | 220 | def __getitem__(self, key): 221 | return self.__attachments[key] 222 | 223 | def __contains__(self, item): 224 | return item in {attachment.name for attachment in self.__attachments} 225 | 226 | def __len__(self): 227 | return len(self.__attachments) 228 | 229 | def __str__(self): 230 | attachments = len(self.__attachments) 231 | parent_has_attachments = getattr(self._parent, 'has_attachments', False) 232 | if parent_has_attachments and attachments == 0: 233 | return 'Number of Attachments: unknown' 234 | else: 235 | return 'Number of Attachments: {}'.format(attachments) 236 | 237 | def __repr__(self): 238 | return self.__str__() 239 | 240 | def __bool__(self): 241 | return bool(len(self.__attachments)) 242 | 243 | def to_api_data(self): 244 | return [attachment.to_api_data() for attachment in self.__attachments if attachment.on_cloud is False] 245 | 246 | def clear(self): 247 | for attachment in self.__attachments: 248 | if attachment.on_cloud: 249 | self.__removed_attachments.append(attachment) 250 | self.__attachments = [] 251 | self._update_parent_attachments() 252 | self._track_changes() 253 | 254 | def _track_changes(self): 255 | """ Update the track_changes on the parent to reflect a needed update on this field """ 256 | if getattr(self._parent, '_track_changes', None) is not None and self.untrack is False: 257 | self._parent._track_changes.add('attachments') 258 | 259 | def _update_parent_attachments(self): 260 | """ Tries to update the parent property 'has_attachments' """ 261 | try: 262 | self._parent.has_attachments = bool(len(self.__attachments)) 263 | except AttributeError: 264 | pass 265 | 266 | def add(self, attachments): 267 | """ Attachments must be a Path or string or a list of Paths, path strings or dictionary elements """ 268 | 269 | if attachments: 270 | if isinstance(attachments, (str, Path)): 271 | attachments = [attachments] 272 | if isinstance(attachments, (list, tuple, set)): 273 | # User provided attachments 274 | attachments_temp = [self._attachment_constructor(attachment, parent=self) 275 | for attachment in attachments] 276 | elif isinstance(attachments, dict) and self._cloud_data_key in attachments: 277 | # Cloud downloaded attachments. We pass on_cloud=True to track if this attachment is saved on the server 278 | attachments_temp = [self._attachment_constructor({self._cloud_data_key: attachment}, parent=self, on_cloud=True) 279 | for attachment in attachments.get(self._cloud_data_key, [])] 280 | else: 281 | raise ValueError('Attachments must be a str or Path or a list, tuple or set of the former') 282 | 283 | self.__attachments.extend(attachments_temp) 284 | self._update_parent_attachments() 285 | self._track_changes() 286 | 287 | def remove(self, attachments): 288 | """ Remove attachments from this collection of attachments """ 289 | if isinstance(attachments, (list, tuple)): 290 | attachments = {attachment.name if isinstance(attachment, BaseAttachment) else attachment for attachment in attachments} 291 | elif isinstance(attachments, str): 292 | attachments = {attachments} 293 | elif isinstance(attachments, BaseAttachment): 294 | attachments = {attachments.name} 295 | else: 296 | raise ValueError('Incorrect parameter type for attachments') 297 | 298 | new_attachments = [] 299 | for attachment in self.__attachments: 300 | if attachment.name not in attachments: 301 | new_attachments.append(attachment) 302 | else: 303 | if attachment.on_cloud: 304 | self.__removed_attachments.append(attachment) # add to removed_attachments so later we can delete them 305 | self.__attachments = new_attachments 306 | self._update_parent_attachments() 307 | self._track_changes() 308 | 309 | def download_attachments(self): 310 | """ Downloads this message attachments into memory. Need a call to 'attachment.save' to save them on disk. """ 311 | 312 | if not self._parent.has_attachments: 313 | log.debug('Parent {} has no attachments, skipping out early.'.format(self._parent.__class__.__name__)) 314 | return False 315 | 316 | if not self._parent.object_id: 317 | raise RuntimeError('Attempted to download attachments of an unsaved {}'.format(self._parent.__class__.__name__)) 318 | 319 | url = self.build_url(self._endpoints.get('attachments').format(id=self._parent.object_id)) 320 | 321 | response = self._parent.con.get(url) 322 | if not response: 323 | return False 324 | 325 | attachments = response.json().get('value', []) 326 | 327 | # Everything received from the cloud must be passed with self._cloud_data_key 328 | self.untrack = True 329 | self.add({self._cloud_data_key: attachments}) 330 | self.untrack = False 331 | 332 | # TODO: when it's a item attachment the attachment itself is not downloaded. We must download it... 333 | # TODO: idea: retrieve the attachments ids' only with select and then download one by one. 334 | return True 335 | 336 | def _update_attachments_to_cloud(self): 337 | """ 338 | Push new, unsaved attachments to the cloud and remove removed attachments 339 | This method should not be called for non draft messages. 340 | """ 341 | 342 | url = self.build_url(self._endpoints.get('attachments').format(id=self._parent.object_id)) 343 | 344 | # ! potencially several api requests can be made by this method. 345 | 346 | for attachment in self.__attachments: 347 | if attachment.on_cloud is False: 348 | # upload attachment: 349 | response = self._parent.con.post(url, data=attachment.to_api_data()) 350 | if not response: 351 | return False 352 | 353 | data = response.json() 354 | 355 | # update attachment data 356 | attachment.attachment_id = data.get('id') 357 | attachment.content = data.get(self._cc('contentBytes'), None) 358 | attachment.on_cloud = True 359 | 360 | for attachment in self.__removed_attachments: 361 | if attachment.on_cloud and attachment.attachment_id is not None: 362 | # delete attachment 363 | url = self.build_url(self._endpoints.get('attachment').format(id=self._parent.object_id, ida=attachment.attachment_id)) 364 | 365 | response = self._parent.con.delete(url) 366 | if not response: 367 | return False 368 | 369 | self.__removed_attachments = [] # reset the removed attachments 370 | 371 | log.debug('Successfully updated attachments on {}'.format(self._parent.object_id)) 372 | 373 | return True 374 | -------------------------------------------------------------------------------- /pyo365/mailbox.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime as dt 3 | 4 | from pyo365.message import Message 5 | from pyo365.utils import Pagination, NEXT_LINK_KEYWORD, OutlookWellKnowFolderNames, ApiComponent 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | class Folder(ApiComponent): 11 | """ A Mail Folder representation """ 12 | 13 | _endpoints = { 14 | 'root_folders': '/mailFolders', 15 | 'child_folders': '/mailFolders/{id}/childFolders', 16 | 'get_folder': '/mailFolders/{id}', 17 | 'root_messages': '/messages', 18 | 'folder_messages': '/mailFolders/{id}/messages', 19 | 'copy_folder': '/mailFolders/{id}/copy', 20 | 'move_folder': '/mailFolders/{id}/move', 21 | 'delete_message': '/messages/{id}', 22 | } 23 | message_constructor = Message 24 | 25 | def __init__(self, *, parent=None, con=None, **kwargs): 26 | assert parent or con, 'Need a parent or a connection' 27 | self.con = parent.con if parent else con 28 | self.parent = parent if isinstance(parent, Folder) else None 29 | 30 | self.root = kwargs.pop('root', False) # This folder has no parents if root = True. 31 | 32 | # Choose the main_resource passed in kwargs over the parent main_resource 33 | main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None 34 | super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) 35 | 36 | cloud_data = kwargs.get(self._cloud_data_key, {}) 37 | 38 | self.name = cloud_data.get(self._cc('displayName'), kwargs.get('name', '')) # Fallback to manual folder 39 | if self.root is False: 40 | self.folder_id = cloud_data.get(self._cc('id'), kwargs.get('folder_id', None)) # Fallback to manual folder 41 | self.parent_id = cloud_data.get(self._cc('parentFolderId'), None) 42 | self.child_folders_count = cloud_data.get(self._cc('childFolderCount'), 0) 43 | self.unread_items_count = cloud_data.get(self._cc('unreadItemCount'), 0) 44 | self.total_items_count = cloud_data.get(self._cc('totalItemCount'), 0) 45 | self.updated_at = dt.datetime.now() 46 | 47 | def __str__(self): 48 | return self.__repr__() 49 | 50 | def __repr__(self): 51 | return '{} from resource: {}'.format(self.name, self.main_resource) 52 | 53 | def get_folders(self, limit=None, *, query=None, order_by=None, batch=None): 54 | """ 55 | Returns a list of child folders 56 | 57 | :param limit: limits the result set. Over 999 uses batch. 58 | :param query: applies a filter to the request such as "displayName eq 'HelloFolder'" 59 | :param order_by: orders the result set based on this condition 60 | :param batch: Returns a custom iterator that retrieves items in batches allowing to retrieve more items than the limit. 61 | """ 62 | 63 | if self.root: 64 | url = self.build_url(self._endpoints.get('root_folders')) 65 | else: 66 | url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id)) 67 | 68 | if limit is None or limit > self.protocol.max_top_value: 69 | batch = self.protocol.max_top_value 70 | 71 | params = {'$top': batch if batch else limit} 72 | 73 | if order_by: 74 | params['$orderby'] = order_by 75 | 76 | if query: 77 | if isinstance(query, str): 78 | params['$filter'] = query 79 | else: 80 | params.update(query.as_params()) 81 | 82 | response = self.con.get(url, params=params) 83 | if not response: 84 | return [] 85 | 86 | data = response.json() 87 | 88 | # Everything received from the cloud must be passed with self._cloud_data_key 89 | self_class = getattr(self, 'folder_constructor', type(self)) 90 | folders = [self_class(parent=self, **{self._cloud_data_key: folder}) for folder in data.get('value', [])] 91 | next_link = data.get(NEXT_LINK_KEYWORD, None) 92 | if batch and next_link: 93 | return Pagination(parent=self, data=folders, constructor=self_class, 94 | next_link=next_link, limit=limit) 95 | else: 96 | return folders 97 | 98 | def get_message(self, query=None, *, download_attachments=False): 99 | """ A shorcut to get_messages with limit=1 """ 100 | messages = self.get_messages(limit=1, query=query, download_attachments=download_attachments) 101 | 102 | return messages[0] if messages else None 103 | 104 | def get_messages(self, limit=25, *, query=None, order_by=None, batch=None, download_attachments=False): 105 | """ 106 | Downloads messages from this folder 107 | 108 | :param limit: limits the result set. Over 999 uses batch. 109 | :param query: applies a filter to the request such as 'displayName:HelloFolder' 110 | :param order_by: orders the result set based on this condition 111 | :param batch: Returns a custom iterator that retrieves items in batches allowing 112 | to retrieve more items than the limit. Download_attachments is ignored. 113 | :param download_attachments: downloads message attachments 114 | """ 115 | 116 | if self.root: 117 | url = self.build_url(self._endpoints.get('root_messages')) 118 | else: 119 | url = self.build_url(self._endpoints.get('folder_messages').format(id=self.folder_id)) 120 | 121 | if limit is None or limit > self.protocol.max_top_value: 122 | batch = self.protocol.max_top_value 123 | 124 | if batch: 125 | download_attachments = False 126 | 127 | params = {'$top': batch if batch else limit} 128 | 129 | if order_by: 130 | params['$orderby'] = order_by 131 | 132 | if query: 133 | if isinstance(query, str): 134 | params['$filter'] = query 135 | else: 136 | params.update(query.as_params()) 137 | 138 | response = self.con.get(url, params=params) 139 | if not response: 140 | return [] 141 | 142 | data = response.json() 143 | 144 | # Everything received from the cloud must be passed with self._cloud_data_key 145 | messages = [self.message_constructor(parent=self, download_attachments=download_attachments, 146 | **{self._cloud_data_key: message}) 147 | for message in data.get('value', [])] 148 | 149 | next_link = data.get(NEXT_LINK_KEYWORD, None) 150 | if batch and next_link: 151 | return Pagination(parent=self, data=messages, constructor=self.message_constructor, 152 | next_link=next_link, limit=limit) 153 | else: 154 | return messages 155 | 156 | def create_child_folder(self, folder_name): 157 | """ 158 | Creates a new child folder 159 | :return the new Folder Object or None 160 | """ 161 | if not folder_name: 162 | return None 163 | 164 | if self.root: 165 | url = self.build_url(self._endpoints.get('root_folders')) 166 | else: 167 | url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id)) 168 | 169 | response = self.con.post(url, data={self._cc('displayName'): folder_name}) 170 | if not response: 171 | return None 172 | 173 | folder = response.json() 174 | 175 | self_class = getattr(self, 'folder_constructor', type(self)) 176 | # Everything received from the cloud must be passed with self._cloud_data_key 177 | return self_class(parent=self, **{self._cloud_data_key: folder}) 178 | 179 | def get_folder(self, *, folder_id=None, folder_name=None): 180 | """ 181 | Returns a folder by it's id or name 182 | :param folder_id: the folder_id to be retrieved. Can be any folder Id (child or not) 183 | :param folder_name: the folder name to be retrieved. Must be a child of this folder. 184 | """ 185 | if folder_id and folder_name: 186 | raise RuntimeError('Provide only one of the options') 187 | 188 | if not folder_id and not folder_name: 189 | raise RuntimeError('Provide one of the options') 190 | 191 | if folder_id: 192 | # get folder by it's id, independent of the parent of this folder_id 193 | url = self.build_url(self._endpoints.get('get_folder').format(id=folder_id)) 194 | params = None 195 | else: 196 | # get folder by name. Only looks up in child folders. 197 | if self.root: 198 | url = self.build_url(self._endpoints.get('root_folders')) 199 | else: 200 | url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id)) 201 | params = {'$filter': "{} eq '{}'".format(self._cc('displayName'), folder_name), '$top': 1} 202 | 203 | response = self.con.get(url, params=params) 204 | if not response: 205 | return None 206 | 207 | if folder_id: 208 | folder = response.json() 209 | else: 210 | folder = response.json().get('value') 211 | folder = folder[0] if folder else None 212 | if folder is None: 213 | return None 214 | 215 | self_class = getattr(self, 'folder_constructor', type(self)) 216 | # Everything received from the cloud must be passed with self._cloud_data_key 217 | # we don't pass parent, as this folder may not be a child of self. 218 | return self_class(con=self.con, protocol=self.protocol, main_resource=self.main_resource, **{self._cloud_data_key: folder}) 219 | 220 | def refresh_folder(self, update_parent_if_changed=False): 221 | """ 222 | Re-donwload folder data 223 | Inbox Folder will be unable to download its own data (no folder_id) 224 | :param update_parent_if_changed: updates self.parent with the new parent Folder if changed 225 | """ 226 | folder_id = getattr(self, 'folder_id', None) 227 | if self.root or folder_id is None: 228 | return False 229 | 230 | folder = self.get_folder(folder_id=folder_id) 231 | if folder is None: 232 | return False 233 | 234 | self.name = folder.name 235 | if folder.parent_id and self.parent_id: 236 | if folder.parent_id != self.parent_id: 237 | self.parent_id = folder.parent_id 238 | self.parent = self.get_parent_folder() if update_parent_if_changed else None 239 | self.child_folders_count = folder.child_folders_count 240 | self.unread_items_count = folder.unread_items_count 241 | self.total_items_count = folder.total_items_count 242 | self.updated_at = folder.updated_at 243 | 244 | return True 245 | 246 | def get_parent_folder(self): 247 | """ Returns the parent folder from attribute self.parent or getting it from the cloud""" 248 | if self.root: 249 | return None 250 | if self.parent: 251 | return self.parent 252 | 253 | if self.parent_id: 254 | self.parent = self.get_folder(folder_id=self.parent_id) 255 | return self.parent 256 | 257 | def update_folder_name(self, name, update_folder_data=True): 258 | """ Change this folder name """ 259 | if self.root: 260 | return False 261 | if not name: 262 | return False 263 | 264 | url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id)) 265 | 266 | response = self.con.patch(url, data={self._cc('displayName'): name}) 267 | if not response: 268 | return False 269 | 270 | self.name = name 271 | if not update_folder_data: 272 | return True 273 | 274 | folder = response.json() 275 | 276 | self.name = folder.get(self._cc('displayName'), '') 277 | self.parent_id = folder.get(self._cc('parentFolderId'), None) 278 | self.child_folders_count = folder.get(self._cc('childFolderCount'), 0) 279 | self.unread_items_count = folder.get(self._cc('unreadItemCount'), 0) 280 | self.total_items_count = folder.get(self._cc('totalItemCount'), 0) 281 | self.updated_at = dt.datetime.now() 282 | 283 | return True 284 | 285 | def delete(self): 286 | """ Deletes this folder """ 287 | 288 | if self.root or not self.folder_id: 289 | return False 290 | 291 | url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id)) 292 | 293 | response = self.con.delete(url) 294 | if not response: 295 | return False 296 | 297 | self.folder_id = None 298 | return True 299 | 300 | def copy_folder(self, to_folder): 301 | """ 302 | Copy this folder and it's contents to into another folder 303 | :param to_folder: the destination Folder instance or a string folder_id 304 | :return The copied folder object 305 | """ 306 | to_folder_id = to_folder.folder_id if isinstance(to_folder, Folder) else to_folder 307 | 308 | if self.root or not self.folder_id or not to_folder_id: 309 | return None 310 | 311 | url = self.build_url(self._endpoints.get('copy_folder').format(id=self.folder_id)) 312 | 313 | response = self.con.post(url, data={self._cc('destinationId'): to_folder_id}) 314 | if not response: 315 | return None 316 | 317 | folder = response.json() 318 | 319 | self_class = getattr(self, 'folder_constructor', type(self)) 320 | # Everything received from the cloud must be passed with self._cloud_data_key 321 | return self_class(con=self.con, main_resource=self.main_resource, **{self._cloud_data_key: folder}) 322 | 323 | def move_folder(self, to_folder, *, update_parent_if_changed=False): 324 | """ 325 | Move this folder to another folder 326 | :param to_folder: the destination Folder instance or a string folder_id 327 | :param update_parent_if_changed: updates self.parent with the new parent Folder if changed 328 | """ 329 | to_folder_id = to_folder.folder_id if isinstance(to_folder, Folder) else to_folder 330 | 331 | if self.root or not self.folder_id or not to_folder_id: 332 | return False 333 | 334 | url = self.build_url(self._endpoints.get('move_folder').format(id=self.folder_id)) 335 | 336 | response = self.con.post(url, data={self._cc('destinationId'): to_folder_id}) 337 | if not response: 338 | return False 339 | 340 | folder = response.json() 341 | 342 | parent_id = folder.get(self._cc('parentFolderId'), None) 343 | 344 | if parent_id and self.parent_id: 345 | if parent_id != self.parent_id: 346 | self.parent_id = parent_id 347 | self.parent = self.get_parent_folder() if update_parent_if_changed else None 348 | 349 | return True 350 | 351 | def new_message(self): 352 | """ Creates a new draft message in this folder """ 353 | 354 | draft_message = self.message_constructor(parent=self, is_draft=True) 355 | 356 | if self.root: 357 | draft_message.folder_id = OutlookWellKnowFolderNames.DRAFTS.value 358 | else: 359 | draft_message.folder_id = self.folder_id 360 | 361 | return draft_message 362 | 363 | def delete_message(self, message): 364 | """ Deletes a stored message by it's id """ 365 | 366 | message_id = message.object_id if isinstance(message, Message) else message 367 | 368 | if message_id is None: 369 | raise RuntimeError('Provide a valid Message or a message id') 370 | 371 | url = self.build_url(self._endpoints.get('delete_message').format(id=message_id)) 372 | 373 | response = self.con.delete(url) 374 | 375 | return bool(response) 376 | 377 | 378 | class MailBox(Folder): 379 | 380 | folder_constructor = Folder 381 | 382 | def __init__(self, *, parent=None, con=None, **kwargs): 383 | super().__init__(parent=parent, con=con, root=True, **kwargs) 384 | 385 | def inbox_folder(self): 386 | """ Returns this mailbox Inbox """ 387 | return self.folder_constructor(parent=self, name='Inbox', folder_id=OutlookWellKnowFolderNames.INBOX.value) 388 | 389 | def junk_folder(self): 390 | """ Returns this mailbox Junk Folder """ 391 | return self.folder_constructor(parent=self, name='Junk', folder_id=OutlookWellKnowFolderNames.JUNK.value) 392 | 393 | def deleted_folder(self): 394 | """ Returns this mailbox DeletedItems Folder """ 395 | return self.folder_constructor(parent=self, name='DeletedItems', folder_id=OutlookWellKnowFolderNames.DELETED.value) 396 | 397 | def drafts_folder(self): 398 | """ Returns this mailbox Drafs Folder """ 399 | return self.folder_constructor(parent=self, name='Drafs', folder_id=OutlookWellKnowFolderNames.DRAFTS.value) 400 | 401 | def sent_folder(self): 402 | """ Returns this mailbox SentItems Folder """ 403 | return self.folder_constructor(parent=self, name='SentItems', folder_id=OutlookWellKnowFolderNames.SENT.value) 404 | 405 | def outbox_folder(self): 406 | """ Returns this mailbox Outbox Folder """ 407 | return self.folder_constructor(parent=self, name='Outbox', folder_id=OutlookWellKnowFolderNames.OUTBOX.value) 408 | -------------------------------------------------------------------------------- /pyo365/address_book.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dateutil.parser import parse 3 | from enum import Enum 4 | 5 | from pyo365.message import HandleRecipientsMixin, Recipients, Message 6 | from pyo365.utils import Pagination, NEXT_LINK_KEYWORD, ApiComponent, AttachableMixin 7 | 8 | GAL_MAIN_RESOURCE = 'users' 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class RecipientType(Enum): 14 | TO = 'to' 15 | CC = 'cc' 16 | BCC = 'bcc' 17 | 18 | 19 | class Contact(ApiComponent, AttachableMixin, HandleRecipientsMixin): 20 | """ Contact manages lists of events on an associated contact on office365. """ 21 | 22 | _mapping = {'display_name': 'displayName', 'name': 'givenName', 'surname': 'surname', 'title': 'title', 'job_title': 'jobTitle', 23 | 'company_name': 'companyName', 'department': 'department', 'office_location': 'officeLocation', 24 | 'business_phones': 'businessPhones', 'mobile_phone': 'mobilePhone', 'home_phones': 'homePhones', 25 | 'emails': 'emailAddresses', 'business_addresses': 'businessAddress', 'home_addresses': 'homesAddress', 26 | 'other_addresses': 'otherAddress', 'categories': 'categories'} 27 | 28 | _endpoints = { 29 | 'root_contact': '/contacts/{id}', 30 | 'child_contact': '/contactFolders/{id}/contacts' 31 | } 32 | message_constructor = Message 33 | 34 | def __init__(self, *, parent=None, con=None, **kwargs): 35 | 36 | assert parent or con, 'Need a parent or a connection' 37 | self.con = parent.con if parent else con 38 | 39 | # Choose the main_resource passed in kwargs over the parent main_resource 40 | main_resource = kwargs.pop('main_resource', None) or (getattr(parent, 'main_resource', None) if parent else None) 41 | super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) 42 | 43 | cloud_data = kwargs.get(self._cloud_data_key, {}) 44 | cc = self._cc # alias to shorten the code 45 | 46 | self.object_id = cloud_data.get(cc('id'), None) 47 | self.created = cloud_data.get(cc('createdDateTime'), None) 48 | self.modified = cloud_data.get(cc('lastModifiedDateTime'), None) 49 | 50 | local_tz = self.protocol.timezone 51 | self.created = parse(self.created).astimezone(local_tz) if self.created else None 52 | self.modified = parse(self.modified).astimezone(local_tz) if self.modified else None 53 | 54 | self.display_name = cloud_data.get(cc('displayName'), '') 55 | self.name = cloud_data.get(cc('givenName'), '') 56 | self.surname = cloud_data.get(cc('surname'), '') 57 | 58 | self.title = cloud_data.get(cc('title'), '') 59 | self.job_title = cloud_data.get(cc('jobTitle'), '') 60 | self.company_name = cloud_data.get(cc('companyName'), '') 61 | self.department = cloud_data.get(cc('department'), '') 62 | self.office_location = cloud_data.get(cc('officeLocation'), '') 63 | self.business_phones = cloud_data.get(cc('businessPhones'), []) or [] 64 | self.mobile_phone = cloud_data.get(cc('mobilePhone'), '') 65 | self.home_phones = cloud_data.get(cc('homePhones'), []) or [] 66 | self.__emails = self._recipients_from_cloud(cloud_data.get(cc('emailAddresses'), [])) 67 | email = cloud_data.get(cc('email')) 68 | if email and email not in self.__emails: 69 | # a Contact from OneDrive? 70 | self.__emails.add(email) 71 | self.business_addresses = cloud_data.get(cc('businessAddress'), {}) 72 | self.home_addresses = cloud_data.get(cc('homesAddress'), {}) 73 | self.other_addresses = cloud_data.get(cc('otherAddress'), {}) 74 | self.preferred_language = cloud_data.get(cc('preferredLanguage'), None) 75 | 76 | self.categories = cloud_data.get(cc('categories'), []) 77 | self.folder_id = cloud_data.get(cc('parentFolderId'), None) 78 | 79 | # when using Users endpoints (GAL) : missing keys: ['mail', 'userPrincipalName'] 80 | mail = cloud_data.get(cc('mail'), None) 81 | user_principal_name = cloud_data.get(cc('userPrincipalName'), None) 82 | if mail and mail not in self.emails: 83 | self.emails.add(mail) 84 | if user_principal_name and user_principal_name not in self.emails: 85 | self.emails.add(user_principal_name) 86 | 87 | @property 88 | def emails(self): 89 | return self.__emails 90 | 91 | @property 92 | def main_email(self): 93 | """ Returns the first email on the emails""" 94 | if not self.emails: 95 | return None 96 | return self.emails[0].address 97 | 98 | @property 99 | def full_name(self): 100 | """ Returns name + surname """ 101 | return '{} {}'.format(self.name, self.surname).strip() 102 | 103 | def __str__(self): 104 | return self.__repr__() 105 | 106 | def __repr__(self): 107 | return self.display_name or self.full_name or 'Unknwon Name' 108 | 109 | def to_api_data(self): 110 | """ Returns a dictionary in cloud format """ 111 | 112 | data = { 113 | 'displayName': self.display_name, 114 | 'givenName': self.name, 115 | 'surname': self.surname, 116 | 'title': self.title, 117 | 'jobTitle': self.job_title, 118 | 'companyName': self.company_name, 119 | 'department': self.department, 120 | 'officeLocation': self.office_location, 121 | 'businessPhones': self.business_phones, 122 | 'mobilePhone': self.mobile_phone, 123 | 'homePhones': self.home_phones, 124 | 'emailAddresses': [self._recipient_to_cloud(recipient) for recipient in self.emails], 125 | 'businessAddress': self.business_addresses, 126 | 'homesAddress': self.home_addresses, 127 | 'otherAddress': self.other_addresses, 128 | 'categories': self.categories} 129 | return data 130 | 131 | def delete(self): 132 | """ Deletes this contact """ 133 | 134 | if not self.object_id: 135 | raise RuntimeError('Attemping to delete an usaved Contact') 136 | 137 | url = self.build_url(self._endpoints.get('contact').format(id=self.object_id)) 138 | 139 | response = self.con.delete(url) 140 | 141 | return bool(response) 142 | 143 | def update(self, fields): 144 | """ Updates a contact 145 | :param fields: a dict of fields to update (field: value). 146 | """ 147 | 148 | if not self.object_id: 149 | raise RuntimeError('Attemping to update an usaved Contact') 150 | 151 | if fields is None or not isinstance(fields, (list, tuple)): 152 | raise ValueError('Must provide fields to update as a list or tuple') 153 | 154 | data = {} 155 | for field in fields: 156 | mapping = self._mapping.get(field) 157 | if mapping is None: 158 | raise ValueError('{} is not a valid updatable field from Contact'.format(field)) 159 | update_value = getattr(self, field) 160 | if isinstance(update_value, Recipients): 161 | data[self._cc(mapping)] = [self._recipient_to_cloud(recipient) for recipient in update_value] 162 | else: 163 | data[self._cc(mapping)] = update_value 164 | 165 | url = self.build_url(self._endpoints.get('contact'.format(id=self.object_id))) 166 | 167 | response = self.con.patch(url, data=data) 168 | 169 | return bool(response) 170 | 171 | def save(self): 172 | """ Saves this Contact to the cloud """ 173 | if self.object_id: 174 | raise RuntimeError("Can't save an existing Contact. Use Update instead. ") 175 | 176 | if self.folder_id: 177 | url = self.build_url(self._endpoints.get('child_contact').format(self.folder_id)) 178 | else: 179 | url = self.build_url(self._endpoints.get('root_contact')) 180 | 181 | response = self.con.post(url, data=self.to_api_data()) 182 | if not response: 183 | return False 184 | 185 | contact = response.json() 186 | 187 | self.object_id = contact.get(self._cc('id'), None) 188 | self.created = contact.get(self._cc('createdDateTime'), None) 189 | self.modified = contact.get(self._cc('lastModifiedDateTime'), None) 190 | 191 | local_tz = self.protocol.timezone 192 | self.created = parse(self.created).astimezone(local_tz) if self.created else None 193 | self.modified = parse(self.modified).astimezone(local_tz) if self.modified else None 194 | 195 | return True 196 | 197 | def new_message(self, recipient=None, *, recipient_type=RecipientType.TO): 198 | """ 199 | This method returns a new draft Message instance with this contact first email as a recipient 200 | :param recipient: a Recipient instance where to send this message. If None, first recipient with address. 201 | :param recipient_type: a RecipientType Enum. 202 | :return: a new draft Message or None if recipient has no addresses 203 | """ 204 | if self.main_resource == GAL_MAIN_RESOURCE: 205 | # preventing the contact lookup to explode for big organizations.. 206 | raise RuntimeError('Sending a message to all users within an Organization is not allowed') 207 | 208 | if isinstance(recipient_type, str): 209 | recipient_type = RecipientType(recipient_type) 210 | 211 | recipient = recipient or self.emails.get_first_recipient_with_address() 212 | if not recipient: 213 | return None 214 | 215 | new_message = self.message_constructor(parent=self, is_draft=True) 216 | 217 | target_recipients = getattr(new_message, str(recipient_type.value)) 218 | target_recipients.add(recipient) 219 | 220 | return new_message 221 | 222 | 223 | class BaseContactFolder(ApiComponent): 224 | """ Base Contact Folder Grouping Functionality """ 225 | 226 | _endpoints = { 227 | 'gal': '', 228 | 'root_contacts': '/contacts', 229 | 'folder_contacts': '/contactFolders/{id}/contacts', 230 | 'get_folder': '/contactFolders/{id}', 231 | 'root_folders': '/contactFolders', 232 | 'child_folders': '/contactFolders/{id}/childFolders' 233 | } 234 | 235 | contact_constructor = Contact 236 | message_constructor = Message 237 | 238 | def __init__(self, *, parent=None, con=None, **kwargs): 239 | assert parent or con, 'Need a parent or a connection' 240 | self.con = parent.con if parent else con 241 | 242 | # Choose the main_resource passed in kwargs over the parent main_resource 243 | main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None 244 | super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) 245 | 246 | self.root = kwargs.pop('root', False) # This folder has no parents if root = True. 247 | 248 | cloud_data = kwargs.get(self._cloud_data_key, {}) 249 | 250 | self.name = cloud_data.get(self._cc('displayName'), kwargs.get('name', None)) # Fallback to manual folder 251 | self.folder_id = cloud_data.get(self._cc('id'), None) 252 | self.parent_id = cloud_data.get(self._cc('parentFolderId'), None) 253 | 254 | def __str__(self): 255 | return self.__repr__() 256 | 257 | def __repr__(self): 258 | return 'Contact Folder: {}'.format(self.name) 259 | 260 | def get_contacts(self, limit=100, *, query=None, order_by=None, batch=None): 261 | """ 262 | Gets a list of contacts from this address book 263 | 264 | When quering the Global Address List the Users enpoint will be used. 265 | Only a limited set of information will be available unless you have acces to 266 | scope 'User.Read.All' wich requires App Administration Consent. 267 | Also using the Users enpoint has some limitations on the quering capabilites. 268 | 269 | To use query an order_by check the OData specification here: 270 | http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html 271 | 272 | :param limit: Number of elements to return. Over 999 uses batch. 273 | :param query: a OData valid filter clause 274 | :param order_by: OData valid order by clause 275 | :param batch: Returns a custom iterator that retrieves items in batches allowing 276 | to retrieve more items than the limit. 277 | """ 278 | 279 | if self.main_resource == GAL_MAIN_RESOURCE: 280 | # using Users endpoint to access the Global Address List 281 | url = self.build_url(self._endpoints.get('gal')) 282 | else: 283 | if self.root: 284 | url = self.build_url(self._endpoints.get('root_contacts')) 285 | else: 286 | url = self.build_url(self._endpoints.get('folder_contacts').format(id=self.folder_id)) 287 | 288 | if limit is None or limit > self.protocol.max_top_value: 289 | batch = self.protocol.max_top_value 290 | 291 | params = {'$top': batch if batch else limit} 292 | 293 | if order_by: 294 | params['$orderby'] = order_by 295 | 296 | if query: 297 | if isinstance(query, str): 298 | params['$filter'] = query 299 | else: 300 | params.update(query.as_params()) 301 | 302 | response = self.con.get(url, params=params) 303 | if not response: 304 | return [] 305 | 306 | data = response.json() 307 | 308 | # Everything received from the cloud must be passed with self._cloud_data_key 309 | contacts = [self.contact_constructor(parent=self, **{self._cloud_data_key: contact}) 310 | for contact in data.get('value', [])] 311 | 312 | next_link = data.get(NEXT_LINK_KEYWORD, None) 313 | 314 | if batch and next_link: 315 | return Pagination(parent=self, data=contacts, constructor=self.contact_constructor, 316 | next_link=next_link, limit=limit) 317 | else: 318 | return contacts 319 | 320 | 321 | class ContactFolder(BaseContactFolder): 322 | """ A Contact Folder representation """ 323 | 324 | def get_folder(self, folder_id=None, folder_name=None): 325 | """ 326 | Returns a ContactFolder by it's id or name 327 | :param folder_id: the folder_id to be retrieved. Can be any folder Id (child or not) 328 | :param folder_name: the folder name to be retrieved. Must be a child of this folder. 329 | """ 330 | 331 | if folder_id and folder_name: 332 | raise RuntimeError('Provide only one of the options') 333 | 334 | if not folder_id and not folder_name: 335 | raise RuntimeError('Provide one of the options') 336 | 337 | if folder_id: 338 | # get folder by it's id, independent of the parent of this folder_id 339 | url = self.build_url(self._endpoints.get('get_folder').format(id=folder_id)) 340 | params = None 341 | else: 342 | # get folder by name. Only looks up in child folders. 343 | if self.root: 344 | url = self.build_url(self._endpoints.get('root_folders')) 345 | else: 346 | url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id)) 347 | 348 | params = {'$filter': "{} eq '{}'".format(self._cc('displayName'), folder_name), '$top': 1} 349 | 350 | response = self.con.get(url, params=params) 351 | if not response: 352 | return None 353 | 354 | if folder_id: 355 | folder = response.json() 356 | else: 357 | folder = response.json().get('value') 358 | folder = folder[0] if folder else None 359 | if folder is None: 360 | return None 361 | 362 | # Everything received from the cloud must be passed with self._cloud_data_key 363 | # we don't pass parent, as this folder may not be a child of self. 364 | return ContactFolder(con=self.con, protocol=self.protocol, main_resource=self.main_resource, **{self._cloud_data_key: folder}) 365 | 366 | def get_folders(self, limit=None, *, query=None, order_by=None): 367 | """ 368 | Returns a list of child folders 369 | 370 | :param limit: Number of elements to return. 371 | :param query: a OData valid filter clause 372 | :param order_by: OData valid order by clause 373 | """ 374 | if self.root: 375 | url = self.build_url(self._endpoints.get('root_folders')) 376 | else: 377 | url = self.build_url(self._endpoints.get('child_folders').format(self.folder_id)) 378 | 379 | params = {} 380 | 381 | if limit: 382 | params['$top'] = limit 383 | 384 | if order_by: 385 | params['$orderby'] = order_by 386 | 387 | if query: 388 | if isinstance(query, str): 389 | params['$filter'] = query 390 | else: 391 | params.update(query.as_params()) 392 | 393 | response = self.con.get(url, params=params or None) 394 | if not response: 395 | return [] 396 | 397 | data = response.json() 398 | 399 | return [ContactFolder(parent=self, **{self._cloud_data_key: folder}) 400 | for folder in data.get('value', [])] 401 | 402 | def create_child_folder(self, folder_name): 403 | """ 404 | Creates a new child folder 405 | :return the new Folder Object or None 406 | """ 407 | 408 | if not folder_name: 409 | return None 410 | 411 | if self.root: 412 | url = self.build_url(self._endpoints.get('root_folders')) 413 | else: 414 | url = self.build_url(self._endpoints.get('child_folders').format(id=self.folder_id)) 415 | 416 | response = self.con.post(url, data={self._cc('displayName'): folder_name}) 417 | if not response: 418 | return None 419 | 420 | folder = response.json() 421 | 422 | # Everything received from the cloud must be passed with self._cloud_data_key 423 | return ContactFolder(parent=self, **{self._cloud_data_key: folder}) 424 | 425 | def update_folder_name(self, name): 426 | """ Change this folder name """ 427 | if self.root: 428 | return False 429 | if not name: 430 | return False 431 | 432 | url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id)) 433 | 434 | response = self.con.patch(url, data={self._cc('displayName'): name}) 435 | if not response: 436 | return False 437 | 438 | folder = response.json() 439 | 440 | self.name = folder.get(self._cc('displayName'), '') 441 | self.parent_id = folder.get(self._cc('parentFolderId'), None) 442 | 443 | return True 444 | 445 | def move_folder(self, to_folder): 446 | """ 447 | Change this folder name 448 | :param to_folder: a folder_id str or a ContactFolder 449 | """ 450 | if self.root: 451 | return False 452 | if not to_folder: 453 | return False 454 | 455 | url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id)) 456 | 457 | if isinstance(to_folder, ContactFolder): 458 | folder_id = to_folder.folder_id 459 | elif isinstance(to_folder, str): 460 | folder_id = to_folder 461 | else: 462 | return False 463 | 464 | response = self.con.patch(url, data={self._cc('parentFolderId'): folder_id}) 465 | if not response: 466 | return False 467 | 468 | folder = response.json() 469 | 470 | self.name = folder.get(self._cc('displayName'), '') 471 | self.parent_id = folder.get(self._cc('parentFolderId'), None) 472 | 473 | return True 474 | 475 | def delete(self): 476 | """ Deletes this folder """ 477 | 478 | if self.root or not self.folder_id: 479 | return False 480 | 481 | url = self.build_url(self._endpoints.get('get_folder').format(id=self.folder_id)) 482 | 483 | response = self.con.delete(url) 484 | if not response: 485 | return False 486 | 487 | self.folder_id = None 488 | 489 | return True 490 | 491 | def new_contact(self): 492 | """ Creates a new contact to be saved into it's parent folder """ 493 | contact = self.contact_constructor(parent=self) 494 | if not self.root: 495 | contact.folder_id = self.folder_id 496 | 497 | return contact 498 | 499 | def new_message(self, recipient_type=RecipientType.TO, *, query=None): 500 | """ 501 | This method returns a new draft Message instance with all the contacts first email as a recipient 502 | :param recipient_type: a RecipientType Enum. 503 | :param query: a query to filter the contacts (passed to get_contacts) 504 | :return: a draft Message or None if no contacts could be retrieved 505 | """ 506 | 507 | if isinstance(recipient_type, str): 508 | recipient_type = RecipientType(recipient_type) 509 | 510 | recipients = [contact.emails[0] 511 | for contact in self.get_contacts(limit=None, query=query) 512 | if contact.emails and contact.emails[0].address] 513 | 514 | if not recipients: 515 | return None 516 | 517 | new_message = self.message_constructor(parent=self, is_draft=True) 518 | target_recipients = getattr(new_message, str(recipient_type.value)) 519 | target_recipients.add(recipients) 520 | 521 | return new_message 522 | 523 | 524 | class AddressBook(ContactFolder): 525 | """ A class representing an address book """ 526 | 527 | def __init__(self, *, parent=None, con=None, **kwargs): 528 | # set instance to be a root instance 529 | super().__init__(parent=parent, con=con, root=True, **kwargs) 530 | 531 | def __repr__(self): 532 | return 'Address Book resource: {}'.format(self.main_resource) 533 | 534 | 535 | class GlobalAddressList(BaseContactFolder): 536 | """ A class representing the Global Address List (Users API) """ 537 | 538 | def __init__(self, *, parent=None, con=None, **kwargs): 539 | # set instance to be a root instance and the main_resource to be the GAL_MAIN_RESOURCE 540 | super().__init__(parent=parent, con=con, root=True, main_resource=GAL_MAIN_RESOURCE, 541 | name='Global Address List', **kwargs) 542 | 543 | def __repr__(self): 544 | return 'Global Address List' 545 | 546 | def get_contact_by_email(self, email): 547 | """ Returns a Contact by it's email """ 548 | 549 | if not email: 550 | return None 551 | 552 | email = email.strip() 553 | 554 | url = self.build_url('{}/{}'.format(self._endpoints.get('gal'), email)) 555 | 556 | response = self.con.get(url) 557 | if not response: 558 | return [] 559 | 560 | data = response.json() 561 | 562 | # Everything received from the cloud must be passed with self._cloud_data_key 563 | return self.contact_constructor(parent=self, **{self._cloud_data_key: data}) 564 | -------------------------------------------------------------------------------- /pyo365/utils/windows_tz.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mapping from iana timezones to windows timezones and vice versa 3 | """ 4 | 5 | IANA_TO_WIN = { 6 | 'Africa/Abidjan': 'Greenwich Standard Time', 7 | 'Africa/Accra': 'Greenwich Standard Time', 8 | 'Africa/Addis_Ababa': 'E. Africa Standard Time', 9 | 'Africa/Algiers': 'W. Central Africa Standard Time', 10 | 'Africa/Asmera': 'E. Africa Standard Time', 11 | 'Africa/Bamako': 'Greenwich Standard Time', 12 | 'Africa/Bangui': 'W. Central Africa Standard Time', 13 | 'Africa/Banjul': 'Greenwich Standard Time', 14 | 'Africa/Bissau': 'Greenwich Standard Time', 15 | 'Africa/Blantyre': 'South Africa Standard Time', 16 | 'Africa/Brazzaville': 'W. Central Africa Standard Time', 17 | 'Africa/Bujumbura': 'South Africa Standard Time', 18 | 'Africa/Cairo': 'Egypt Standard Time', 19 | 'Africa/Casablanca': 'Morocco Standard Time', 20 | 'Africa/Ceuta': 'Romance Standard Time', 21 | 'Africa/Conakry': 'Greenwich Standard Time', 22 | 'Africa/Dakar': 'Greenwich Standard Time', 23 | 'Africa/Dar_es_Salaam': 'E. Africa Standard Time', 24 | 'Africa/Djibouti': 'E. Africa Standard Time', 25 | 'Africa/Douala': 'W. Central Africa Standard Time', 26 | 'Africa/El_Aaiun': 'Morocco Standard Time', 27 | 'Africa/Freetown': 'Greenwich Standard Time', 28 | 'Africa/Gaborone': 'South Africa Standard Time', 29 | 'Africa/Harare': 'South Africa Standard Time', 30 | 'Africa/Johannesburg': 'South Africa Standard Time', 31 | 'Africa/Juba': 'E. Africa Standard Time', 32 | 'Africa/Kampala': 'E. Africa Standard Time', 33 | 'Africa/Khartoum': 'Sudan Standard Time', 34 | 'Africa/Kigali': 'South Africa Standard Time', 35 | 'Africa/Kinshasa': 'W. Central Africa Standard Time', 36 | 'Africa/Lagos': 'W. Central Africa Standard Time', 37 | 'Africa/Libreville': 'W. Central Africa Standard Time', 38 | 'Africa/Lome': 'Greenwich Standard Time', 39 | 'Africa/Luanda': 'W. Central Africa Standard Time', 40 | 'Africa/Lubumbashi': 'South Africa Standard Time', 41 | 'Africa/Lusaka': 'South Africa Standard Time', 42 | 'Africa/Malabo': 'W. Central Africa Standard Time', 43 | 'Africa/Maputo': 'South Africa Standard Time', 44 | 'Africa/Maseru': 'South Africa Standard Time', 45 | 'Africa/Mbabane': 'South Africa Standard Time', 46 | 'Africa/Mogadishu': 'E. Africa Standard Time', 47 | 'Africa/Monrovia': 'Greenwich Standard Time', 48 | 'Africa/Nairobi': 'E. Africa Standard Time', 49 | 'Africa/Ndjamena': 'W. Central Africa Standard Time', 50 | 'Africa/Niamey': 'W. Central Africa Standard Time', 51 | 'Africa/Nouakchott': 'Greenwich Standard Time', 52 | 'Africa/Ouagadougou': 'Greenwich Standard Time', 53 | 'Africa/Porto-Novo': 'W. Central Africa Standard Time', 54 | 'Africa/Sao_Tome': 'W. Central Africa Standard Time', 55 | 'Africa/Tripoli': 'Libya Standard Time', 56 | 'Africa/Tunis': 'W. Central Africa Standard Time', 57 | 'Africa/Windhoek': 'Namibia Standard Time', 58 | 'America/Adak': 'Aleutian Standard Time', 59 | 'America/Anchorage': 'Alaskan Standard Time', 60 | 'America/Anguilla': 'SA Western Standard Time', 61 | 'America/Antigua': 'SA Western Standard Time', 62 | 'America/Araguaina': 'Tocantins Standard Time', 63 | 'America/Argentina/La_Rioja': 'Argentina Standard Time', 64 | 'America/Argentina/Rio_Gallegos': 'Argentina Standard Time', 65 | 'America/Argentina/Salta': 'Argentina Standard Time', 66 | 'America/Argentina/San_Juan': 'Argentina Standard Time', 67 | 'America/Argentina/San_Luis': 'Argentina Standard Time', 68 | 'America/Argentina/Tucuman': 'Argentina Standard Time', 69 | 'America/Argentina/Ushuaia': 'Argentina Standard Time', 70 | 'America/Aruba': 'SA Western Standard Time', 71 | 'America/Asuncion': 'Paraguay Standard Time', 72 | 'America/Bahia': 'Bahia Standard Time', 73 | 'America/Bahia_Banderas': 'Central Standard Time (Mexico)', 74 | 'America/Barbados': 'SA Western Standard Time', 75 | 'America/Belem': 'SA Eastern Standard Time', 76 | 'America/Belize': 'Central America Standard Time', 77 | 'America/Blanc-Sablon': 'SA Western Standard Time', 78 | 'America/Boa_Vista': 'SA Western Standard Time', 79 | 'America/Bogota': 'SA Pacific Standard Time', 80 | 'America/Boise': 'Mountain Standard Time', 81 | 'America/Buenos_Aires': 'Argentina Standard Time', 82 | 'America/Cambridge_Bay': 'Mountain Standard Time', 83 | 'America/Campo_Grande': 'Central Brazilian Standard Time', 84 | 'America/Cancun': 'Eastern Standard Time (Mexico)', 85 | 'America/Caracas': 'Venezuela Standard Time', 86 | 'America/Catamarca': 'Argentina Standard Time', 87 | 'America/Cayenne': 'SA Eastern Standard Time', 88 | 'America/Cayman': 'SA Pacific Standard Time', 89 | 'America/Chicago': 'Central Standard Time', 90 | 'America/Chihuahua': 'Mountain Standard Time (Mexico)', 91 | 'America/Coral_Harbour': 'SA Pacific Standard Time', 92 | 'America/Cordoba': 'Argentina Standard Time', 93 | 'America/Costa_Rica': 'Central America Standard Time', 94 | 'America/Creston': 'US Mountain Standard Time', 95 | 'America/Cuiaba': 'Central Brazilian Standard Time', 96 | 'America/Curacao': 'SA Western Standard Time', 97 | 'America/Danmarkshavn': 'UTC', 98 | 'America/Dawson': 'Pacific Standard Time', 99 | 'America/Dawson_Creek': 'US Mountain Standard Time', 100 | 'America/Denver': 'Mountain Standard Time', 101 | 'America/Detroit': 'Eastern Standard Time', 102 | 'America/Dominica': 'SA Western Standard Time', 103 | 'America/Edmonton': 'Mountain Standard Time', 104 | 'America/Eirunepe': 'SA Pacific Standard Time', 105 | 'America/El_Salvador': 'Central America Standard Time', 106 | 'America/Fort_Nelson': 'US Mountain Standard Time', 107 | 'America/Fortaleza': 'SA Eastern Standard Time', 108 | 'America/Glace_Bay': 'Atlantic Standard Time', 109 | 'America/Godthab': 'Greenland Standard Time', 110 | 'America/Goose_Bay': 'Atlantic Standard Time', 111 | 'America/Grand_Turk': 'Turks And Caicos Standard Time', 112 | 'America/Grenada': 'SA Western Standard Time', 113 | 'America/Guadeloupe': 'SA Western Standard Time', 114 | 'America/Guatemala': 'Central America Standard Time', 115 | 'America/Guayaquil': 'SA Pacific Standard Time', 116 | 'America/Guyana': 'SA Western Standard Time', 117 | 'America/Halifax': 'Atlantic Standard Time', 118 | 'America/Havana': 'Cuba Standard Time', 119 | 'America/Hermosillo': 'US Mountain Standard Time', 120 | 'America/Indiana/Knox': 'Central Standard Time', 121 | 'America/Indiana/Marengo': 'US Eastern Standard Time', 122 | 'America/Indiana/Petersburg': 'Eastern Standard Time', 123 | 'America/Indiana/Tell_City': 'Central Standard Time', 124 | 'America/Indiana/Vevay': 'US Eastern Standard Time', 125 | 'America/Indiana/Vincennes': 'Eastern Standard Time', 126 | 'America/Indiana/Winamac': 'Eastern Standard Time', 127 | 'America/Indianapolis': 'US Eastern Standard Time', 128 | 'America/Inuvik': 'Mountain Standard Time', 129 | 'America/Iqaluit': 'Eastern Standard Time', 130 | 'America/Jamaica': 'SA Pacific Standard Time', 131 | 'America/Jujuy': 'Argentina Standard Time', 132 | 'America/Juneau': 'Alaskan Standard Time', 133 | 'America/Kentucky/Monticello': 'Eastern Standard Time', 134 | 'America/Kralendijk': 'SA Western Standard Time', 135 | 'America/La_Paz': 'SA Western Standard Time', 136 | 'America/Lima': 'SA Pacific Standard Time', 137 | 'America/Los_Angeles': 'Pacific Standard Time', 138 | 'America/Louisville': 'Eastern Standard Time', 139 | 'America/Lower_Princes': 'SA Western Standard Time', 140 | 'America/Maceio': 'SA Eastern Standard Time', 141 | 'America/Managua': 'Central America Standard Time', 142 | 'America/Manaus': 'SA Western Standard Time', 143 | 'America/Marigot': 'SA Western Standard Time', 144 | 'America/Martinique': 'SA Western Standard Time', 145 | 'America/Matamoros': 'Central Standard Time', 146 | 'America/Mazatlan': 'Mountain Standard Time (Mexico)', 147 | 'America/Mendoza': 'Argentina Standard Time', 148 | 'America/Menominee': 'Central Standard Time', 149 | 'America/Merida': 'Central Standard Time (Mexico)', 150 | 'America/Metlakatla': 'Alaskan Standard Time', 151 | 'America/Mexico_City': 'Central Standard Time (Mexico)', 152 | 'America/Miquelon': 'Saint Pierre Standard Time', 153 | 'America/Moncton': 'Atlantic Standard Time', 154 | 'America/Monterrey': 'Central Standard Time (Mexico)', 155 | 'America/Montevideo': 'Montevideo Standard Time', 156 | 'America/Montreal': 'Eastern Standard Time', 157 | 'America/Montserrat': 'SA Western Standard Time', 158 | 'America/Nassau': 'Eastern Standard Time', 159 | 'America/New_York': 'Eastern Standard Time', 160 | 'America/Nipigon': 'Eastern Standard Time', 161 | 'America/Nome': 'Alaskan Standard Time', 162 | 'America/Noronha': 'UTC-02', 163 | 'America/North_Dakota/Beulah': 'Central Standard Time', 164 | 'America/North_Dakota/Center': 'Central Standard Time', 165 | 'America/North_Dakota/New_Salem': 'Central Standard Time', 166 | 'America/Ojinaga': 'Mountain Standard Time', 167 | 'America/Panama': 'SA Pacific Standard Time', 168 | 'America/Pangnirtung': 'Eastern Standard Time', 169 | 'America/Paramaribo': 'SA Eastern Standard Time', 170 | 'America/Phoenix': 'US Mountain Standard Time', 171 | 'America/Port-au-Prince': 'Haiti Standard Time', 172 | 'America/Port_of_Spain': 'SA Western Standard Time', 173 | 'America/Porto_Velho': 'SA Western Standard Time', 174 | 'America/Puerto_Rico': 'SA Western Standard Time', 175 | 'America/Punta_Arenas': 'Magallanes Standard Time', 176 | 'America/Rainy_River': 'Central Standard Time', 177 | 'America/Rankin_Inlet': 'Central Standard Time', 178 | 'America/Recife': 'SA Eastern Standard Time', 179 | 'America/Regina': 'Canada Central Standard Time', 180 | 'America/Resolute': 'Central Standard Time', 181 | 'America/Rio_Branco': 'SA Pacific Standard Time', 182 | 'America/Santa_Isabel': 'Pacific Standard Time (Mexico)', 183 | 'America/Santarem': 'SA Eastern Standard Time', 184 | 'America/Santiago': 'Pacific SA Standard Time', 185 | 'America/Santo_Domingo': 'SA Western Standard Time', 186 | 'America/Sao_Paulo': 'E. South America Standard Time', 187 | 'America/Scoresbysund': 'Azores Standard Time', 188 | 'America/Sitka': 'Alaskan Standard Time', 189 | 'America/St_Barthelemy': 'SA Western Standard Time', 190 | 'America/St_Johns': 'Newfoundland Standard Time', 191 | 'America/St_Kitts': 'SA Western Standard Time', 192 | 'America/St_Lucia': 'SA Western Standard Time', 193 | 'America/St_Thomas': 'SA Western Standard Time', 194 | 'America/St_Vincent': 'SA Western Standard Time', 195 | 'America/Swift_Current': 'Canada Central Standard Time', 196 | 'America/Tegucigalpa': 'Central America Standard Time', 197 | 'America/Thule': 'Atlantic Standard Time', 198 | 'America/Thunder_Bay': 'Eastern Standard Time', 199 | 'America/Tijuana': 'Pacific Standard Time (Mexico)', 200 | 'America/Toronto': 'Eastern Standard Time', 201 | 'America/Tortola': 'SA Western Standard Time', 202 | 'America/Vancouver': 'Pacific Standard Time', 203 | 'America/Whitehorse': 'Pacific Standard Time', 204 | 'America/Winnipeg': 'Central Standard Time', 205 | 'America/Yakutat': 'Alaskan Standard Time', 206 | 'America/Yellowknife': 'Mountain Standard Time', 207 | 'Antarctica/Casey': 'W. Australia Standard Time', 208 | 'Antarctica/Davis': 'SE Asia Standard Time', 209 | 'Antarctica/DumontDUrville': 'West Pacific Standard Time', 210 | 'Antarctica/Macquarie': 'Central Pacific Standard Time', 211 | 'Antarctica/Mawson': 'West Asia Standard Time', 212 | 'Antarctica/McMurdo': 'New Zealand Standard Time', 213 | 'Antarctica/Palmer': 'Magallanes Standard Time', 214 | 'Antarctica/Rothera': 'SA Eastern Standard Time', 215 | 'Antarctica/Syowa': 'E. Africa Standard Time', 216 | 'Antarctica/Vostok': 'Central Asia Standard Time', 217 | 'Arctic/Longyearbyen': 'W. Europe Standard Time', 218 | 'Asia/Aden': 'Arab Standard Time', 219 | 'Asia/Almaty': 'Central Asia Standard Time', 220 | 'Asia/Amman': 'Jordan Standard Time', 221 | 'Asia/Anadyr': 'Russia Time Zone 11', 222 | 'Asia/Aqtau': 'West Asia Standard Time', 223 | 'Asia/Aqtobe': 'West Asia Standard Time', 224 | 'Asia/Ashgabat': 'West Asia Standard Time', 225 | 'Asia/Atyrau': 'West Asia Standard Time', 226 | 'Asia/Baghdad': 'Arabic Standard Time', 227 | 'Asia/Bahrain': 'Arab Standard Time', 228 | 'Asia/Baku': 'Azerbaijan Standard Time', 229 | 'Asia/Bangkok': 'SE Asia Standard Time', 230 | 'Asia/Barnaul': 'Altai Standard Time', 231 | 'Asia/Beirut': 'Middle East Standard Time', 232 | 'Asia/Bishkek': 'Central Asia Standard Time', 233 | 'Asia/Brunei': 'Singapore Standard Time', 234 | 'Asia/Calcutta': 'India Standard Time', 235 | 'Asia/Chita': 'Transbaikal Standard Time', 236 | 'Asia/Choibalsan': 'Ulaanbaatar Standard Time', 237 | 'Asia/Colombo': 'Sri Lanka Standard Time', 238 | 'Asia/Damascus': 'Syria Standard Time', 239 | 'Asia/Dhaka': 'Bangladesh Standard Time', 240 | 'Asia/Dili': 'Tokyo Standard Time', 241 | 'Asia/Dubai': 'Arabian Standard Time', 242 | 'Asia/Dushanbe': 'West Asia Standard Time', 243 | 'Asia/Famagusta': 'Turkey Standard Time', 244 | 'Asia/Gaza': 'West Bank Standard Time', 245 | 'Asia/Hebron': 'West Bank Standard Time', 246 | 'Asia/Hong_Kong': 'China Standard Time', 247 | 'Asia/Hovd': 'W. Mongolia Standard Time', 248 | 'Asia/Irkutsk': 'North Asia East Standard Time', 249 | 'Asia/Jakarta': 'SE Asia Standard Time', 250 | 'Asia/Jayapura': 'Tokyo Standard Time', 251 | 'Asia/Jerusalem': 'Israel Standard Time', 252 | 'Asia/Kabul': 'Afghanistan Standard Time', 253 | 'Asia/Kamchatka': 'Russia Time Zone 11', 254 | 'Asia/Karachi': 'Pakistan Standard Time', 255 | 'Asia/Katmandu': 'Nepal Standard Time', 256 | 'Asia/Khandyga': 'Yakutsk Standard Time', 257 | 'Asia/Kolkata': 'India Standard Time', 258 | 'Asia/Krasnoyarsk': 'North Asia Standard Time', 259 | 'Asia/Kuala_Lumpur': 'Singapore Standard Time', 260 | 'Asia/Kuching': 'Singapore Standard Time', 261 | 'Asia/Kuwait': 'Arab Standard Time', 262 | 'Asia/Macau': 'China Standard Time', 263 | 'Asia/Magadan': 'Magadan Standard Time', 264 | 'Asia/Makassar': 'Singapore Standard Time', 265 | 'Asia/Manila': 'Singapore Standard Time', 266 | 'Asia/Muscat': 'Arabian Standard Time', 267 | 'Asia/Nicosia': 'GTB Standard Time', 268 | 'Asia/Novokuznetsk': 'North Asia Standard Time', 269 | 'Asia/Novosibirsk': 'N. Central Asia Standard Time', 270 | 'Asia/Omsk': 'Omsk Standard Time', 271 | 'Asia/Oral': 'West Asia Standard Time', 272 | 'Asia/Phnom_Penh': 'SE Asia Standard Time', 273 | 'Asia/Pontianak': 'SE Asia Standard Time', 274 | 'Asia/Pyongyang': 'North Korea Standard Time', 275 | 'Asia/Qatar': 'Arab Standard Time', 276 | 'Asia/Qyzylorda': 'Central Asia Standard Time', 277 | 'Asia/Rangoon': 'Myanmar Standard Time', 278 | 'Asia/Riyadh': 'Arab Standard Time', 279 | 'Asia/Saigon': 'SE Asia Standard Time', 280 | 'Asia/Sakhalin': 'Sakhalin Standard Time', 281 | 'Asia/Samarkand': 'West Asia Standard Time', 282 | 'Asia/Seoul': 'Korea Standard Time', 283 | 'Asia/Shanghai': 'China Standard Time', 284 | 'Asia/Singapore': 'Singapore Standard Time', 285 | 'Asia/Srednekolymsk': 'Russia Time Zone 10', 286 | 'Asia/Taipei': 'Taipei Standard Time', 287 | 'Asia/Tashkent': 'West Asia Standard Time', 288 | 'Asia/Tbilisi': 'Georgian Standard Time', 289 | 'Asia/Tehran': 'Iran Standard Time', 290 | 'Asia/Thimphu': 'Bangladesh Standard Time', 291 | 'Asia/Tokyo': 'Tokyo Standard Time', 292 | 'Asia/Tomsk': 'Tomsk Standard Time', 293 | 'Asia/Ulaanbaatar': 'Ulaanbaatar Standard Time', 294 | 'Asia/Urumqi': 'Central Asia Standard Time', 295 | 'Asia/Ust-Nera': 'Vladivostok Standard Time', 296 | 'Asia/Vientiane': 'SE Asia Standard Time', 297 | 'Asia/Vladivostok': 'Vladivostok Standard Time', 298 | 'Asia/Yakutsk': 'Yakutsk Standard Time', 299 | 'Asia/Yekaterinburg': 'Ekaterinburg Standard Time', 300 | 'Asia/Yerevan': 'Caucasus Standard Time', 301 | 'Atlantic/Azores': 'Azores Standard Time', 302 | 'Atlantic/Bermuda': 'Atlantic Standard Time', 303 | 'Atlantic/Canary': 'GMT Standard Time', 304 | 'Atlantic/Cape_Verde': 'Cape Verde Standard Time', 305 | 'Atlantic/Faeroe': 'GMT Standard Time', 306 | 'Atlantic/Madeira': 'GMT Standard Time', 307 | 'Atlantic/Reykjavik': 'Greenwich Standard Time', 308 | 'Atlantic/South_Georgia': 'UTC-02', 309 | 'Atlantic/St_Helena': 'Greenwich Standard Time', 310 | 'Atlantic/Stanley': 'SA Eastern Standard Time', 311 | 'Australia/Adelaide': 'Cen. Australia Standard Time', 312 | 'Australia/Brisbane': 'E. Australia Standard Time', 313 | 'Australia/Broken_Hill': 'Cen. Australia Standard Time', 314 | 'Australia/Currie': 'Tasmania Standard Time', 315 | 'Australia/Darwin': 'AUS Central Standard Time', 316 | 'Australia/Eucla': 'Aus Central W. Standard Time', 317 | 'Australia/Hobart': 'Tasmania Standard Time', 318 | 'Australia/Lindeman': 'E. Australia Standard Time', 319 | 'Australia/Lord_Howe': 'Lord Howe Standard Time', 320 | 'Australia/Melbourne': 'AUS Eastern Standard Time', 321 | 'Australia/Perth': 'W. Australia Standard Time', 322 | 'Australia/Sydney': 'AUS Eastern Standard Time', 323 | 'CST6CDT': 'Central Standard Time', 324 | 'EST5EDT': 'Eastern Standard Time', 325 | 'Etc/GMT': 'UTC', 326 | 'Etc/GMT+1': 'Cape Verde Standard Time', 327 | 'Etc/GMT+10': 'Hawaiian Standard Time', 328 | 'Etc/GMT+11': 'UTC-11', 329 | 'Etc/GMT+12': 'Dateline Standard Time', 330 | 'Etc/GMT+2': 'UTC-02', 331 | 'Etc/GMT+3': 'SA Eastern Standard Time', 332 | 'Etc/GMT+4': 'SA Western Standard Time', 333 | 'Etc/GMT+5': 'SA Pacific Standard Time', 334 | 'Etc/GMT+6': 'Central America Standard Time', 335 | 'Etc/GMT+7': 'US Mountain Standard Time', 336 | 'Etc/GMT+8': 'UTC-08', 337 | 'Etc/GMT+9': 'UTC-09', 338 | 'Etc/GMT-1': 'W. Central Africa Standard Time', 339 | 'Etc/GMT-10': 'West Pacific Standard Time', 340 | 'Etc/GMT-11': 'Central Pacific Standard Time', 341 | 'Etc/GMT-12': 'UTC+12', 342 | 'Etc/GMT-13': 'UTC+13', 343 | 'Etc/GMT-14': 'Line Islands Standard Time', 344 | 'Etc/GMT-2': 'South Africa Standard Time', 345 | 'Etc/GMT-3': 'E. Africa Standard Time', 346 | 'Etc/GMT-4': 'Arabian Standard Time', 347 | 'Etc/GMT-5': 'West Asia Standard Time', 348 | 'Etc/GMT-6': 'Central Asia Standard Time', 349 | 'Etc/GMT-7': 'SE Asia Standard Time', 350 | 'Etc/GMT-8': 'Singapore Standard Time', 351 | 'Etc/GMT-9': 'Tokyo Standard Time', 352 | 'Etc/UTC': 'UTC', 353 | 'Europe/Amsterdam': 'W. Europe Standard Time', 354 | 'Europe/Andorra': 'W. Europe Standard Time', 355 | 'Europe/Astrakhan': 'Astrakhan Standard Time', 356 | 'Europe/Athens': 'GTB Standard Time', 357 | 'Europe/Belgrade': 'Central Europe Standard Time', 358 | 'Europe/Berlin': 'W. Europe Standard Time', 359 | 'Europe/Bratislava': 'Central Europe Standard Time', 360 | 'Europe/Brussels': 'Romance Standard Time', 361 | 'Europe/Bucharest': 'GTB Standard Time', 362 | 'Europe/Budapest': 'Central Europe Standard Time', 363 | 'Europe/Busingen': 'W. Europe Standard Time', 364 | 'Europe/Chisinau': 'E. Europe Standard Time', 365 | 'Europe/Copenhagen': 'Romance Standard Time', 366 | 'Europe/Dublin': 'GMT Standard Time', 367 | 'Europe/Gibraltar': 'W. Europe Standard Time', 368 | 'Europe/Guernsey': 'GMT Standard Time', 369 | 'Europe/Helsinki': 'FLE Standard Time', 370 | 'Europe/Isle_of_Man': 'GMT Standard Time', 371 | 'Europe/Istanbul': 'Turkey Standard Time', 372 | 'Europe/Jersey': 'GMT Standard Time', 373 | 'Europe/Kaliningrad': 'Kaliningrad Standard Time', 374 | 'Europe/Kiev': 'FLE Standard Time', 375 | 'Europe/Kirov': 'Russian Standard Time', 376 | 'Europe/Lisbon': 'GMT Standard Time', 377 | 'Europe/Ljubljana': 'Central Europe Standard Time', 378 | 'Europe/London': 'GMT Standard Time', 379 | 'Europe/Luxembourg': 'W. Europe Standard Time', 380 | 'Europe/Madrid': 'Romance Standard Time', 381 | 'Europe/Malta': 'W. Europe Standard Time', 382 | 'Europe/Mariehamn': 'FLE Standard Time', 383 | 'Europe/Minsk': 'Belarus Standard Time', 384 | 'Europe/Monaco': 'W. Europe Standard Time', 385 | 'Europe/Moscow': 'Russian Standard Time', 386 | 'Europe/Oslo': 'W. Europe Standard Time', 387 | 'Europe/Paris': 'Romance Standard Time', 388 | 'Europe/Podgorica': 'Central Europe Standard Time', 389 | 'Europe/Prague': 'Central Europe Standard Time', 390 | 'Europe/Riga': 'FLE Standard Time', 391 | 'Europe/Rome': 'W. Europe Standard Time', 392 | 'Europe/Samara': 'Russia Time Zone 3', 393 | 'Europe/San_Marino': 'W. Europe Standard Time', 394 | 'Europe/Sarajevo': 'Central European Standard Time', 395 | 'Europe/Saratov': 'Saratov Standard Time', 396 | 'Europe/Simferopol': 'Russian Standard Time', 397 | 'Europe/Skopje': 'Central European Standard Time', 398 | 'Europe/Sofia': 'FLE Standard Time', 399 | 'Europe/Stockholm': 'W. Europe Standard Time', 400 | 'Europe/Tallinn': 'FLE Standard Time', 401 | 'Europe/Tirane': 'Central Europe Standard Time', 402 | 'Europe/Ulyanovsk': 'Astrakhan Standard Time', 403 | 'Europe/Uzhgorod': 'FLE Standard Time', 404 | 'Europe/Vaduz': 'W. Europe Standard Time', 405 | 'Europe/Vatican': 'W. Europe Standard Time', 406 | 'Europe/Vienna': 'W. Europe Standard Time', 407 | 'Europe/Vilnius': 'FLE Standard Time', 408 | 'Europe/Volgograd': 'Russian Standard Time', 409 | 'Europe/Warsaw': 'Central European Standard Time', 410 | 'Europe/Zagreb': 'Central European Standard Time', 411 | 'Europe/Zaporozhye': 'FLE Standard Time', 412 | 'Europe/Zurich': 'W. Europe Standard Time', 413 | 'GMT': 'GMT Standard Time', 414 | 'GB': 'GMT Standard Time', 415 | 'Indian/Antananarivo': 'E. Africa Standard Time', 416 | 'Indian/Chagos': 'Central Asia Standard Time', 417 | 'Indian/Christmas': 'SE Asia Standard Time', 418 | 'Indian/Cocos': 'Myanmar Standard Time', 419 | 'Indian/Comoro': 'E. Africa Standard Time', 420 | 'Indian/Kerguelen': 'West Asia Standard Time', 421 | 'Indian/Mahe': 'Mauritius Standard Time', 422 | 'Indian/Maldives': 'West Asia Standard Time', 423 | 'Indian/Mauritius': 'Mauritius Standard Time', 424 | 'Indian/Mayotte': 'E. Africa Standard Time', 425 | 'Indian/Reunion': 'Mauritius Standard Time', 426 | 'MST7MDT': 'Mountain Standard Time', 427 | 'PST8PDT': 'Pacific Standard Time', 428 | 'Pacific/Apia': 'Samoa Standard Time', 429 | 'Pacific/Auckland': 'New Zealand Standard Time', 430 | 'Pacific/Bougainville': 'Bougainville Standard Time', 431 | 'Pacific/Chatham': 'Chatham Islands Standard Time', 432 | 'Pacific/Easter': 'Easter Island Standard Time', 433 | 'Pacific/Efate': 'Central Pacific Standard Time', 434 | 'Pacific/Enderbury': 'UTC+13', 435 | 'Pacific/Fakaofo': 'UTC+13', 436 | 'Pacific/Fiji': 'Fiji Standard Time', 437 | 'Pacific/Funafuti': 'UTC+12', 438 | 'Pacific/Galapagos': 'Central America Standard Time', 439 | 'Pacific/Gambier': 'UTC-09', 440 | 'Pacific/Guadalcanal': 'Central Pacific Standard Time', 441 | 'Pacific/Guam': 'West Pacific Standard Time', 442 | 'Pacific/Honolulu': 'Hawaiian Standard Time', 443 | 'Pacific/Johnston': 'Hawaiian Standard Time', 444 | 'Pacific/Kiritimati': 'Line Islands Standard Time', 445 | 'Pacific/Kosrae': 'Central Pacific Standard Time', 446 | 'Pacific/Kwajalein': 'UTC+12', 447 | 'Pacific/Majuro': 'UTC+12', 448 | 'Pacific/Marquesas': 'Marquesas Standard Time', 449 | 'Pacific/Midway': 'UTC-11', 450 | 'Pacific/Nauru': 'UTC+12', 451 | 'Pacific/Niue': 'UTC-11', 452 | 'Pacific/Norfolk': 'Norfolk Standard Time', 453 | 'Pacific/Noumea': 'Central Pacific Standard Time', 454 | 'Pacific/Pago_Pago': 'UTC-11', 455 | 'Pacific/Palau': 'Tokyo Standard Time', 456 | 'Pacific/Pitcairn': 'UTC-08', 457 | 'Pacific/Ponape': 'Central Pacific Standard Time', 458 | 'Pacific/Port_Moresby': 'West Pacific Standard Time', 459 | 'Pacific/Rarotonga': 'Hawaiian Standard Time', 460 | 'Pacific/Saipan': 'West Pacific Standard Time', 461 | 'Pacific/Tahiti': 'Hawaiian Standard Time', 462 | 'Pacific/Tarawa': 'UTC+12', 463 | 'Pacific/Tongatapu': 'Tonga Standard Time', 464 | 'Pacific/Truk': 'West Pacific Standard Time', 465 | 'Pacific/Wake': 'UTC+12', 466 | 'Pacific/Wallis': 'UTC+12', 467 | 'UTC': 'UTC', 468 | } 469 | 470 | WIN_TO_IANA = {v: k for k, v in IANA_TO_WIN.items()} 471 | -------------------------------------------------------------------------------- /pyo365/message.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime as dt 3 | from dateutil.parser import parse 4 | import pytz 5 | from bs4 import BeautifulSoup as bs 6 | 7 | from pyo365.utils import OutlookWellKnowFolderNames, ApiComponent, BaseAttachments, BaseAttachment, AttachableMixin, ImportanceLevel, TrackerSet 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class Recipient: 13 | """ A single Recipient """ 14 | 15 | def __init__(self, address=None, name=None, parent=None, field=None): 16 | self._address = address or '' 17 | self._name = name or '' 18 | self._parent = parent 19 | self._field = field 20 | 21 | def __bool__(self): 22 | return bool(self.address) 23 | 24 | def __str__(self): 25 | return self.__repr__() 26 | 27 | def __repr__(self): 28 | if self.name: 29 | return '{} ({})'.format(self.name, self.address) 30 | else: 31 | return self.address 32 | 33 | def _track_changes(self): 34 | """ Update the track_changes on the parent to reflect a needed update on this field """ 35 | if self._field and getattr(self._parent, '_track_changes', None) is not None: 36 | self._parent._track_changes.add(self._field) 37 | 38 | @property 39 | def address(self): 40 | return self._address 41 | 42 | @address.setter 43 | def address(self, value): 44 | self._address = value 45 | self._track_changes() 46 | 47 | @property 48 | def name(self): 49 | return self._name 50 | 51 | @name.setter 52 | def name(self, value): 53 | self._name = value 54 | self._track_changes() 55 | 56 | 57 | class Recipients: 58 | """ A Sequence of Recipients """ 59 | 60 | def __init__(self, recipients=None, parent=None, field=None): 61 | """ Recipients must be a list of either address strings or tuples (name, address) or dictionary elements """ 62 | self._parent = parent 63 | self._field = field 64 | self._recipients = [] 65 | self.untrack = True 66 | if recipients: 67 | self.add(recipients) 68 | self.untrack = False 69 | 70 | def __iter__(self): 71 | return iter(self._recipients) 72 | 73 | def __getitem__(self, key): 74 | return self._recipients[key] 75 | 76 | def __contains__(self, item): 77 | return item in {recipient.address for recipient in self._recipients} 78 | 79 | def __bool__(self): 80 | return bool(len(self._recipients)) 81 | 82 | def __len__(self): 83 | return len(self._recipients) 84 | 85 | def __str__(self): 86 | return self.__repr__() 87 | 88 | def __repr__(self): 89 | return 'Recipients count: {}'.format(len(self._recipients)) 90 | 91 | def _track_changes(self): 92 | """ Update the track_changes on the parent to reflect a needed update on this field """ 93 | if self._field and getattr(self._parent, '_track_changes', None) is not None and self.untrack is False: 94 | self._parent._track_changes.add(self._field) 95 | 96 | def clear(self): 97 | self._recipients = [] 98 | self._track_changes() 99 | 100 | def add(self, recipients): 101 | """ Recipients must be a list of either address strings or tuples (name, address) or dictionary elements """ 102 | 103 | if recipients: 104 | if isinstance(recipients, str): 105 | self._recipients.append(Recipient(address=recipients, parent=self._parent, field=self._field)) 106 | elif isinstance(recipients, Recipient): 107 | self._recipients.append(recipients) 108 | elif isinstance(recipients, tuple): 109 | name, address = recipients 110 | if address: 111 | self._recipients.append(Recipient(address=address, name=name, parent=self._parent, field=self._field)) 112 | elif isinstance(recipients, list): 113 | for recipient in recipients: 114 | self.add(recipient) 115 | else: 116 | raise ValueError('Recipients must be an address string, a' 117 | ' Recipient instance, a (name, address) tuple or a list') 118 | self._track_changes() 119 | 120 | def remove(self, address): 121 | """ Remove an address or multiple addreses """ 122 | recipients = [] 123 | if isinstance(address, str): 124 | address = {address} # set 125 | elif isinstance(address, (list, tuple)): 126 | address = set(address) 127 | 128 | for recipient in self._recipients: 129 | if recipient.address not in address: 130 | recipients.append(recipient) 131 | if len(recipients) != len(self._recipients): 132 | self._track_changes() 133 | self._recipients = recipients 134 | 135 | def get_first_recipient_with_address(self): 136 | """ Returns the first recipient found with a non blank address""" 137 | recipients_with_address = [recipient for recipient in self._recipients if recipient.address] 138 | if recipients_with_address: 139 | return recipients_with_address[0] 140 | else: 141 | return None 142 | 143 | 144 | class MessageAttachment(BaseAttachment): 145 | 146 | _endpoints = { 147 | 'attach': '/messages/{id}/attachments', 148 | 'attachment': '/messages/{id}/attachments/{ida}' 149 | } 150 | 151 | 152 | class MessageAttachments(BaseAttachments): 153 | 154 | _endpoints = { 155 | 'attachments': '/messages/{id}/attachments', 156 | 'attachment': '/messages/{id}/attachments/{ida}' 157 | } 158 | _attachment_constructor = MessageAttachment 159 | 160 | 161 | class HandleRecipientsMixin: 162 | 163 | def _recipients_from_cloud(self, recipients, field=None): 164 | """ Transform a recipient from cloud data to object data """ 165 | recipients_data = [] 166 | for recipient in recipients: 167 | recipients_data.append(self._recipient_from_cloud(recipient, field=field)) 168 | return Recipients(recipients_data, parent=self, field=field) 169 | 170 | def _recipient_from_cloud(self, recipient, field=None): 171 | """ Transform a recipient from cloud data to object data """ 172 | 173 | if recipient: 174 | recipient = recipient.get(self._cc('emailAddress'), recipient if isinstance(recipient, dict) else {}) 175 | address = recipient.get(self._cc('address'), '') 176 | name = recipient.get(self._cc('name'), '') 177 | return Recipient(address=address, name=name, parent=self, field=field) 178 | else: 179 | return Recipient() 180 | 181 | def _recipient_to_cloud(self, recipient): 182 | """ Transforms a Recipient object to a cloud dict """ 183 | data = None 184 | if recipient: 185 | data = {self._cc('emailAddress'): {self._cc('address'): recipient.address}} 186 | if recipient.name: 187 | data[self._cc('emailAddress')][self._cc('name')] = recipient.name 188 | return data 189 | 190 | 191 | class Message(ApiComponent, AttachableMixin, HandleRecipientsMixin): 192 | """ Management of the process of sending, receiving, reading, and editing emails. """ 193 | 194 | _endpoints = { 195 | 'create_draft': '/messages', 196 | 'create_draft_folder': '/mailFolders/{id}/messages', 197 | 'send_mail': '/sendMail', 198 | 'send_draft': '/messages/{id}/send', 199 | 'get_message': '/messages/{id}', 200 | 'move_message': '/messages/{id}/move', 201 | 'copy_message': '/messages/{id}/copy', 202 | 'create_reply': '/messages/{id}/createReply', 203 | 'create_reply_all': '/messages/{id}/createReplyAll', 204 | 'forward_message': '/messages/{id}/createForward' 205 | } 206 | 207 | def __init__(self, *, parent=None, con=None, **kwargs): 208 | """ 209 | Makes a new message wrapper for sending and receiving messages. 210 | 211 | :param parent: the parent object 212 | :param con: the id of this message if it exists 213 | """ 214 | assert parent or con, 'Need a parent or a connection' 215 | self.con = parent.con if parent else con 216 | 217 | # Choose the main_resource passed in kwargs over the parent main_resource 218 | main_resource = kwargs.pop('main_resource', None) or getattr(parent, 'main_resource', None) if parent else None 219 | super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource, 220 | attachment_name_property='subject', attachment_type='message_type') 221 | 222 | download_attachments = kwargs.get('download_attachments') 223 | 224 | cloud_data = kwargs.get(self._cloud_data_key, {}) 225 | cc = self._cc # alias to shorten the code 226 | 227 | self._track_changes = TrackerSet(casing=cc) # internal to know which properties need to be updated on the server 228 | self.object_id = cloud_data.get(cc('id'), None) 229 | 230 | self.__created = cloud_data.get(cc('createdDateTime'), None) 231 | self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None) 232 | self.__received = cloud_data.get(cc('receivedDateTime'), None) 233 | self.__sent = cloud_data.get(cc('sentDateTime'), None) 234 | 235 | local_tz = self.protocol.timezone 236 | self.__created = parse(self.__created).astimezone(local_tz) if self.__created else None 237 | self.__modified = parse(self.__modified).astimezone(local_tz) if self.__modified else None 238 | self.__received = parse(self.__received).astimezone(local_tz) if self.__received else None 239 | self.__sent = parse(self.__sent).astimezone(local_tz) if self.__sent else None 240 | 241 | self.__attachments = MessageAttachments(parent=self, attachments=[]) 242 | self.has_attachments = cloud_data.get(cc('hasAttachments'), 0) 243 | if self.has_attachments and download_attachments: 244 | self.attachments.download_attachments() 245 | self.__subject = cloud_data.get(cc('subject'), '') 246 | body = cloud_data.get(cc('body'), {}) 247 | self.__body = body.get(cc('content'), '') 248 | self.body_type = body.get(cc('contentType'), 'HTML') # default to HTML for new messages 249 | self.__sender = self._recipient_from_cloud(cloud_data.get(cc('from'), None), field='from') 250 | self.__to = self._recipients_from_cloud(cloud_data.get(cc('toRecipients'), []), field='toRecipients') 251 | self.__cc = self._recipients_from_cloud(cloud_data.get(cc('ccRecipients'), []), field='ccRecipients') 252 | self.__bcc = self._recipients_from_cloud(cloud_data.get(cc('bccRecipients'), []), field='bccRecipients') 253 | self.__reply_to = self._recipients_from_cloud(cloud_data.get(cc('replyTo'), []), field='replyTo') 254 | self.__categories = cloud_data.get(cc('categories'), []) 255 | self.__importance = ImportanceLevel((cloud_data.get(cc('importance'), 'normal') or 'normal').lower()) # lower because of office365 v1.0 256 | self.__is_read = cloud_data.get(cc('isRead'), None) 257 | self.__is_draft = cloud_data.get(cc('isDraft'), kwargs.get('is_draft', True)) # a message is a draft by default 258 | self.conversation_id = cloud_data.get(cc('conversationId'), None) 259 | self.folder_id = cloud_data.get(cc('parentFolderId'), None) 260 | 261 | def _clear_tracker(self): 262 | # reset the tracked changes. Usually after a server update 263 | self._track_changes = TrackerSet(casing=self._cc) 264 | 265 | @property 266 | def is_read(self): 267 | return self.__is_read 268 | 269 | @is_read.setter 270 | def is_read(self, value): 271 | self.__is_read = value 272 | self._track_changes.add('isRead') 273 | 274 | @property 275 | def is_draft(self): 276 | return self.__is_draft 277 | 278 | @property 279 | def subject(self): 280 | return self.__subject 281 | 282 | @subject.setter 283 | def subject(self, value): 284 | self.__subject = value 285 | self._track_changes.add('subject') 286 | 287 | @property 288 | def body(self): 289 | return self.__body 290 | 291 | @body.setter 292 | def body(self, value): 293 | if self.__body: 294 | if not value: 295 | self.__body = '' 296 | else: 297 | soup = bs(self.__body, 'html.parser') 298 | soup.body.insert(0, bs(value, 'html.parser')) 299 | self.__body = str(soup) 300 | else: 301 | self.__body = value 302 | self._track_changes.add('body') 303 | 304 | @property 305 | def created(self): 306 | return self.__created 307 | 308 | @property 309 | def modified(self): 310 | return self.__modified 311 | 312 | @property 313 | def received(self): 314 | return self.__received 315 | 316 | @property 317 | def sent(self): 318 | return self.__sent 319 | 320 | @property 321 | def attachments(self): 322 | """ Just to avoid api misuse by assigning to 'attachments' """ 323 | return self.__attachments 324 | 325 | @property 326 | def sender(self): 327 | """ sender is a property to force to be allways a Recipient class """ 328 | return self.__sender 329 | 330 | @sender.setter 331 | def sender(self, value): 332 | """ sender is a property to force to be allways a Recipient class """ 333 | if isinstance(value, Recipient): 334 | if value._parent is None: 335 | value._parent = self 336 | value._field = 'from' 337 | self.__sender = value 338 | elif isinstance(value, str): 339 | self.__sender.address = value 340 | self.__sender.name = '' 341 | else: 342 | raise ValueError('sender must be an address string or a Recipient object') 343 | self._track_changes.add('from') 344 | 345 | @property 346 | def to(self): 347 | """ Just to avoid api misuse by assigning to 'to' """ 348 | return self.__to 349 | 350 | @property 351 | def cc(self): 352 | """ Just to avoid api misuse by assigning to 'cc' """ 353 | return self.__cc 354 | 355 | @property 356 | def bcc(self): 357 | """ Just to avoid api misuse by assigning to 'bcc' """ 358 | return self.__bcc 359 | 360 | @property 361 | def reply_to(self): 362 | """ Just to avoid api misuse by assigning to 'reply_to' """ 363 | return self.__reply_to 364 | 365 | @property 366 | def categories(self): 367 | return self.__categories 368 | 369 | @categories.setter 370 | def categories(self, value): 371 | if isinstance(value, list): 372 | self.__categories = value 373 | elif isinstance(value, str): 374 | self.__categories = [value] 375 | elif isinstance(value, tuple): 376 | self.__categories = list(value) 377 | else: 378 | raise ValueError('categories must be a list') 379 | self._track_changes.add('categories') 380 | 381 | @property 382 | def importance(self): 383 | return self.__importance 384 | 385 | @importance.setter 386 | def importance(self, value): 387 | self.__importance = value if isinstance(value, ImportanceLevel) else ImportanceLevel(value.lower()) 388 | self._track_changes.add('importance') 389 | 390 | def to_api_data(self, restrict_keys=None): 391 | """ Returns a dict representation of this message prepared to be send to the cloud 392 | :param restrict_keys: a set of keys to restrict the returned data to. 393 | """ 394 | 395 | cc = self._cc # alias to shorten the code 396 | 397 | message = { 398 | cc('subject'): self.subject, 399 | cc('body'): { 400 | cc('contentType'): self.body_type, 401 | cc('content'): self.body}, 402 | cc('importance'): self.importance.value 403 | } 404 | 405 | if self.to: 406 | message[cc('toRecipients')] = [self._recipient_to_cloud(recipient) for recipient in self.to] 407 | if self.cc: 408 | message[cc('ccRecipients')] = [self._recipient_to_cloud(recipient) for recipient in self.cc] 409 | if self.bcc: 410 | message[cc('bccRecipients')] = [self._recipient_to_cloud(recipient) for recipient in self.bcc] 411 | if self.reply_to: 412 | message[cc('replyTo')] = [self._recipient_to_cloud(recipient) for recipient in self.reply_to] 413 | if self.attachments: 414 | message[cc('attachments')] = self.attachments.to_api_data() 415 | if self.sender and self.sender.address: 416 | message[cc('from')] = self._recipient_to_cloud(self.sender) 417 | 418 | if self.object_id and not self.__is_draft: 419 | # return the whole signature of this message 420 | 421 | message[cc('id')] = self.object_id 422 | message[cc('createdDateTime')] = self.created.astimezone(pytz.utc).isoformat() 423 | message[cc('receivedDateTime')] = self.received.astimezone(pytz.utc).isoformat() 424 | message[cc('sentDateTime')] = self.sent.astimezone(pytz.utc).isoformat() 425 | message[cc('hasAttachments')] = len(self.attachments) > 0 426 | message[cc('categories')] = self.categories 427 | message[cc('isRead')] = self.is_read 428 | message[cc('isDraft')] = self.__is_draft 429 | message[cc('conversationId')] = self.conversation_id 430 | message[cc('parentFolderId')] = self.folder_id # this property does not form part of the message itself 431 | 432 | if restrict_keys: 433 | for key in list(message.keys()): 434 | if key not in restrict_keys: 435 | del message[key] 436 | 437 | return message 438 | 439 | def send(self, save_to_sent_folder=True): 440 | """ Sends this message. """ 441 | 442 | if self.object_id and not self.__is_draft: 443 | return RuntimeError('Not possible to send a message that is not new or a draft. Use Reply or Forward instead.') 444 | 445 | if self.__is_draft and self.object_id: 446 | url = self.build_url(self._endpoints.get('send_draft').format(id=self.object_id)) 447 | data = None 448 | else: 449 | url = self.build_url(self._endpoints.get('send_mail')) 450 | data = {self._cc('message'): self.to_api_data()} 451 | if save_to_sent_folder is False: 452 | data[self._cc('saveToSentItems')] = False 453 | 454 | response = self.con.post(url, data=data) 455 | if not response: # response evaluates to false if 4XX or 5XX status codes are returned 456 | return False 457 | 458 | self.object_id = 'sent_message' if not self.object_id else self.object_id 459 | self.__is_draft = False 460 | 461 | return True 462 | 463 | def reply(self, to_all=True): 464 | """ 465 | Creates a new message that is a reply to this message. 466 | :param to_all: replies to all the recipients instead to just the sender 467 | """ 468 | if not self.object_id or self.__is_draft: 469 | raise RuntimeError("Can't reply to this message") 470 | 471 | if to_all: 472 | url = self.build_url(self._endpoints.get('create_reply_all').format(id=self.object_id)) 473 | else: 474 | url = self.build_url(self._endpoints.get('create_reply').format(id=self.object_id)) 475 | 476 | response = self.con.post(url) 477 | if not response: 478 | return None 479 | 480 | message = response.json() 481 | 482 | # Everything received from the cloud must be passed with self._cloud_data_key 483 | return self.__class__(parent=self, **{self._cloud_data_key: message}) 484 | 485 | def forward(self): 486 | """ 487 | Creates a new message that is a forward of this message. 488 | """ 489 | if not self.object_id or self.__is_draft: 490 | raise RuntimeError("Can't forward this message") 491 | 492 | url = self.build_url(self._endpoints.get('forward_message').format(id=self.object_id)) 493 | 494 | response = self.con.post(url) 495 | if not response: 496 | return None 497 | 498 | message = response.json() 499 | 500 | # Everything received from the cloud must be passed with self._cloud_data_key 501 | return self.__class__(parent=self, **{self._cloud_data_key: message}) 502 | 503 | def delete(self): 504 | """ Deletes a stored message """ 505 | if self.object_id is None: 506 | raise RuntimeError('Attempting to delete an unsaved Message') 507 | 508 | url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id)) 509 | 510 | response = self.con.delete(url) 511 | 512 | return bool(response) 513 | 514 | def mark_as_read(self): 515 | """ Marks this message as read in the cloud.""" 516 | if self.object_id is None or self.__is_draft: 517 | raise RuntimeError('Attempting to mark as read an unsaved Message') 518 | 519 | data = {self._cc('isRead'): True} 520 | 521 | url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id)) 522 | 523 | response = self.con.patch(url, data=data) 524 | if not response: 525 | return False 526 | 527 | self.__is_read = True 528 | 529 | return True 530 | 531 | def move(self, folder): 532 | """ 533 | Move the message to a given folder 534 | 535 | :param folder: Folder object or Folder id or Well-known name to move this message to 536 | :returns: True on success 537 | """ 538 | if self.object_id is None: 539 | raise RuntimeError('Attempting to move an unsaved Message') 540 | 541 | url = self.build_url(self._endpoints.get('move_message').format(id=self.object_id)) 542 | 543 | if isinstance(folder, str): 544 | folder_id = folder 545 | else: 546 | folder_id = getattr(folder, 'folder_id', None) 547 | 548 | if not folder_id: 549 | raise RuntimeError('Must Provide a valid folder_id') 550 | 551 | data = {self._cc('destinationId'): folder_id} 552 | 553 | response = self.con.post(url, data=data) 554 | if not response: 555 | return False 556 | 557 | self.folder_id = folder_id 558 | 559 | return True 560 | 561 | def copy(self, folder): 562 | """ 563 | Copy the message to a given folder 564 | 565 | :param folder: Folder object or Folder id or Well-known name to move this message to 566 | :returns: the copied message 567 | """ 568 | if self.object_id is None: 569 | raise RuntimeError('Attempting to move an unsaved Message') 570 | 571 | url = self.build_url(self._endpoints.get('copy_message').format(id=self.object_id)) 572 | 573 | if isinstance(folder, str): 574 | folder_id = folder 575 | else: 576 | folder_id = getattr(folder, 'folder_id', None) 577 | 578 | if not folder_id: 579 | raise RuntimeError('Must Provide a valid folder_id') 580 | 581 | data = {self._cc('destinationId'): folder_id} 582 | 583 | response = self.con.post(url, data=data) 584 | if not response: 585 | return None 586 | 587 | message = response.json() 588 | 589 | # Everything received from the cloud must be passed with self._cloud_data_key 590 | return self.__class__(parent=self, **{self._cloud_data_key: message}) 591 | 592 | def save_draft(self, target_folder=OutlookWellKnowFolderNames.DRAFTS): 593 | """ Save this message as a draft on the cloud """ 594 | 595 | if self.object_id: 596 | # update message. Attachments are NOT included nor saved. 597 | if not self.__is_draft: 598 | raise RuntimeError('Only draft messages can be updated') 599 | if not self._track_changes: 600 | return True # there's nothing to update 601 | url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id)) 602 | method = self.con.patch 603 | data = self.to_api_data(restrict_keys=self._track_changes) 604 | 605 | data.pop(self._cc('attachments'), None) # attachments are handled by the next method call 606 | self.attachments._update_attachments_to_cloud() 607 | else: 608 | # new message. Attachments are included and saved. 609 | if not self.__is_draft: 610 | raise RuntimeError('Only draft messages can be saved as drafts') 611 | 612 | target_folder = target_folder or OutlookWellKnowFolderNames.DRAFTS 613 | if isinstance(target_folder, OutlookWellKnowFolderNames): 614 | target_folder = target_folder.value 615 | elif not isinstance(target_folder, str): 616 | # a Folder instance 617 | target_folder = getattr(target_folder, 'folder_id', OutlookWellKnowFolderNames.DRAFTS.value) 618 | 619 | url = self.build_url(self._endpoints.get('create_draft_folder').format(id=target_folder)) 620 | method = self.con.post 621 | data = self.to_api_data() 622 | 623 | self._clear_tracker() # reset the tracked changes as they are all saved. 624 | if not data: 625 | return True 626 | 627 | response = method(url, data=data) 628 | if not response: 629 | return False 630 | 631 | if not self.object_id: 632 | # new message 633 | message = response.json() 634 | 635 | self.object_id = message.get(self._cc('id'), None) 636 | self.folder_id = message.get(self._cc('parentFolderId'), None) 637 | 638 | self.__created = message.get(self._cc('createdDateTime'), message.get(self._cc('dateTimeCreated'), None)) # fallback to office365 v1.0 639 | self.__modified = message.get(self._cc('lastModifiedDateTime'), message.get(self._cc('dateTimeModified'), None)) # fallback to office365 v1.0 640 | 641 | self.__created = parse(self.__created).astimezone(self.protocol.timezone) if self.__created else None 642 | self.__modified = parse(self.__modified).astimezone(self.protocol.timezone) if self.__modified else None 643 | 644 | else: 645 | self.__modified = self.protocol.timezone.localize(dt.datetime.now()) 646 | 647 | return True 648 | 649 | def get_body_text(self): 650 | """ Parse the body html and returns the body text using bs4 """ 651 | if self.body_type != 'HTML': 652 | return self.body 653 | 654 | try: 655 | soup = bs(self.body, 'html.parser') 656 | except Exception as e: 657 | return self.body 658 | else: 659 | return soup.body.text 660 | 661 | def get_body_soup(self): 662 | """ Returns the beautifulsoup4 of the html body""" 663 | if self.body_type != 'HTML': 664 | return None 665 | else: 666 | return bs(self.body, 'html.parser') 667 | 668 | def __str__(self): 669 | return self.__repr__() 670 | 671 | def __repr__(self): 672 | return 'Subject: {}'.format(self.subject) 673 | -------------------------------------------------------------------------------- /pyo365/connection.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import os 4 | import time 5 | from pathlib import Path 6 | from tzlocal import get_localzone 7 | from datetime import tzinfo 8 | import pytz 9 | 10 | from stringcase import pascalcase, camelcase, snakecase 11 | from requests import Session 12 | from requests.adapters import HTTPAdapter 13 | from requests.packages.urllib3.util.retry import Retry # dynamic loading of module Retry by requests.packages 14 | from requests.exceptions import HTTPError, RequestException, ProxyError, SSLError, Timeout, ConnectionError 15 | from oauthlib.oauth2 import TokenExpiredError 16 | from requests_oauthlib import OAuth2Session 17 | 18 | from pyo365.utils import ME_RESOURCE, IANA_TO_WIN, WIN_TO_IANA 19 | 20 | log = logging.getLogger(__name__) 21 | 22 | O365_API_VERSION = 'v2.0' # v2.0 does not allow basic auth 23 | GRAPH_API_VERSION = 'v1.0' 24 | OAUTH_REDIRECT_URL = 'https://outlook.office365.com/owa/' 25 | 26 | RETRIES_STATUS_LIST = (429, 500, 502, 503, 504) # 429 is the TooManyRequests status code. 27 | RETRIES_BACKOFF_FACTOR = 0.5 28 | 29 | 30 | DEFAULT_SCOPES = { 31 | 'basic': [('offline_access',), 'User.Read'], # wrap any scope in a 1 element tuple to avoid prefixing 32 | 'mailbox': ['Mail.Read'], 33 | 'mailbox_shared': ['Mail.Read.Shared'], 34 | 'message_send': ['Mail.Send'], 35 | 'message_send_shared': ['Mail.Send.Shared'], 36 | 'message_all': ['Mail.ReadWrite', 'Mail.Send'], 37 | 'message_all_shared': ['Mail.ReadWrite.Shared', 'Mail.Send.Shared'], 38 | 'address_book': ['Contacts.Read'], 39 | 'address_book_shared': ['Contacts.Read.Shared'], 40 | 'address_book_all': ['Contacts.ReadWrite'], 41 | 'address_book_all_shared': ['Contacts.ReadWrite.Shared'], 42 | 'calendar': ['Calendars.ReadWrite'], 43 | 'users': ['User.ReadBasic.All'], 44 | 'onedrive': ['Files.ReadWrite.All'], 45 | 'sharepoint_dl': ['Sites.ReadWrite.All'], 46 | } 47 | 48 | DEFAULT_SCOPES_OFFICE365 = {scope: value for scope, value in DEFAULT_SCOPES.items() if scope not in {'onedrive', 'sharepoint_dl'}} 49 | 50 | 51 | class Protocol: 52 | """ Base class for all protocols """ 53 | 54 | _protocol_url = 'not_defined' # Main url to request. Override in subclass 55 | _oauth_scope_prefix = '' # prefix for scopes (in MS GRAPH is 'https://graph.microsoft.com/' + SCOPE) 56 | _oauth_scopes = {} # dictionary of {scopes_name: [scope1, scope2]} 57 | 58 | def __init__(self, *, protocol_url=None, api_version=None, default_resource=ME_RESOURCE, 59 | casing_function=None, protocol_scope_prefix=None, timezone=None, **kwargs): 60 | """ 61 | :param protocol_url: the base url used to comunicate with the server 62 | :param api_version: the api version 63 | :param default_resource: the default resource to use when there's no other option 64 | :param casing_function: the casing transform function to be used on api keywords 65 | :param protocol_scope_prefix: prefix for scopes (in MS GRAPH is 'https://graph.microsoft.com/' + SCOPE) 66 | :param timezone: prefered timezone, defaults to the system timezone 67 | """ 68 | if protocol_url is None or api_version is None: 69 | raise ValueError('Must provide valid protocol_url and api_version values') 70 | self.protocol_url = protocol_url or self._protocol_url 71 | self.protocol_scope_prefix = protocol_scope_prefix or '' 72 | self.api_version = api_version 73 | self.service_url = '{}{}/'.format(protocol_url, api_version) 74 | self.default_resource = default_resource 75 | self.use_default_casing = True if casing_function is None else False # if true just returns the key without transform 76 | self.casing_function = casing_function or camelcase 77 | self.timezone = timezone or get_localzone() # pytz timezone 78 | self.max_top_value = 500 # Max $top parameter value 79 | 80 | # define any keyword that can be different in this protocol 81 | self.keyword_data_store = {} 82 | 83 | def get_service_keyword(self, keyword): 84 | """ Returns the data set to the key in the internal data-key dict """ 85 | return self.keyword_data_store.get(keyword, None) 86 | 87 | def convert_case(self, dict_key): 88 | """ Returns a key converted with this protocol casing method 89 | 90 | Converts case to send/read from the cloud 91 | When using Microsoft Graph API, the keywords of the API use lowerCamelCase Casing. 92 | When using Office 365 API, the keywords of the API use PascalCase Casing. 93 | 94 | Default case in this API is lowerCamelCase. 95 | 96 | :param dict_key: a dictionary key to convert 97 | """ 98 | return dict_key if self.use_default_casing else self.casing_function(dict_key) 99 | 100 | @staticmethod 101 | def to_api_case(dict_key): 102 | """ Converts keys to snake case """ 103 | return snakecase(dict_key) 104 | 105 | def get_scopes_for(self, user_provided_scopes): 106 | """ Returns a list of scopes needed for each of the scope_helpers provided 107 | :param user_provided_scopes: a list of scopes or scope helpers 108 | """ 109 | if user_provided_scopes is None: 110 | # return all available scopes 111 | user_provided_scopes = [app_part for app_part in self._oauth_scopes] 112 | elif isinstance(user_provided_scopes, str): 113 | user_provided_scopes = [user_provided_scopes] 114 | 115 | if not isinstance(user_provided_scopes, (list, tuple)): 116 | raise ValueError("'user_provided_scopes' must be a list or a tuple of strings") 117 | 118 | scopes = set() 119 | for app_part in user_provided_scopes: 120 | for scope in self._oauth_scopes.get(app_part, [app_part]): 121 | scopes.add(self._prefix_scope(scope)) 122 | 123 | return list(scopes) 124 | 125 | def _prefix_scope(self, scope): 126 | """ Inserts the protocol scope prefix """ 127 | if self.protocol_scope_prefix: 128 | if isinstance(scope, tuple): 129 | return scope[0] 130 | elif scope.startswith(self.protocol_scope_prefix): 131 | return scope 132 | else: 133 | return '{}{}'.format(self.protocol_scope_prefix, scope) 134 | else: 135 | if isinstance(scope, tuple): 136 | return scope[0] 137 | else: 138 | return scope 139 | 140 | @staticmethod 141 | def get_iana_tz(windows_tz): 142 | """ Returns a valid pytz TimeZone (Iana/Olson Timezones) from a given windows TimeZone 143 | Note: Windows Timezones are SHIT! 144 | """ 145 | timezone = WIN_TO_IANA.get(windows_tz) 146 | if timezone is None: 147 | # Nope, that didn't work. Try adding "Standard Time", 148 | # it seems to work a lot of times: 149 | timezone = WIN_TO_IANA.get(windows_tz + ' Standard Time') 150 | 151 | # Return what we have. 152 | if timezone is None: 153 | raise pytz.UnknownTimeZoneError("Can't find Windows TimeZone " + windows_tz) 154 | 155 | return timezone 156 | 157 | def get_windows_tz(self, iana_tz=None): 158 | """ Returns a valid windows TimeZone from a given pytz TimeZone (Iana/Olson Timezones) 159 | Note: Windows Timezones are SHIT!... no ... really THEY ARE HOLY FUCKING SHIT!. 160 | """ 161 | iana_tz = iana_tz or self.timezone 162 | timezone = IANA_TO_WIN.get(iana_tz.zone if isinstance(iana_tz, tzinfo) else iana_tz) 163 | if timezone is None: 164 | raise pytz.UnknownTimeZoneError("Can't find Iana TimeZone " + iana_tz.zone) 165 | 166 | return timezone 167 | 168 | 169 | class MSGraphProtocol(Protocol): 170 | """ A Microsoft Graph Protocol Implementation 171 | https://docs.microsoft.com/en-us/outlook/rest/compare-graph-outlook 172 | """ 173 | 174 | _protocol_url = 'https://graph.microsoft.com/' 175 | _oauth_scope_prefix = 'https://graph.microsoft.com/' 176 | _oauth_scopes = DEFAULT_SCOPES 177 | 178 | def __init__(self, api_version='v1.0', default_resource=ME_RESOURCE, **kwargs): 179 | super().__init__(protocol_url=self._protocol_url, api_version=api_version, 180 | default_resource=default_resource, casing_function=camelcase, 181 | protocol_scope_prefix=self._oauth_scope_prefix, **kwargs) 182 | 183 | self.keyword_data_store['message_type'] = 'microsoft.graph.message' 184 | self.keyword_data_store['file_attachment_type'] = '#microsoft.graph.fileAttachment' 185 | self.keyword_data_store['item_attachment_type'] = '#microsoft.graph.itemAttachment' 186 | self.max_top_value = 999 # Max $top parameter value 187 | 188 | 189 | class MSOffice365Protocol(Protocol): 190 | """ A Microsoft Office 365 Protocol Implementation 191 | https://docs.microsoft.com/en-us/outlook/rest/compare-graph-outlook 192 | """ 193 | 194 | _protocol_url = 'https://outlook.office.com/api/' 195 | _oauth_scope_prefix = 'https://outlook.office.com/' 196 | _oauth_scopes = DEFAULT_SCOPES_OFFICE365 197 | 198 | def __init__(self, api_version='v2.0', default_resource=ME_RESOURCE, **kwargs): 199 | super().__init__(protocol_url=self._protocol_url, api_version=api_version, 200 | default_resource=default_resource, casing_function=pascalcase, 201 | protocol_scope_prefix=self._oauth_scope_prefix, **kwargs) 202 | 203 | self.keyword_data_store['message_type'] = 'Microsoft.OutlookServices.Message' 204 | self.keyword_data_store['file_attachment_type'] = '#Microsoft.OutlookServices.FileAttachment' 205 | self.keyword_data_store['item_attachment_type'] = '#Microsoft.OutlookServices.ItemAttachment' 206 | self.max_top_value = 999 # Max $top parameter value 207 | 208 | 209 | class Connection: 210 | """ Handles all comunication (requests) between the app and the server """ 211 | 212 | _oauth2_authorize_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize' 213 | _oauth2_token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' 214 | _default_token_file = 'o365_token.txt' 215 | _default_token_path = Path() / _default_token_file 216 | _allowed_methods = ['get', 'post', 'put', 'patch', 'delete'] 217 | 218 | def __init__(self, credentials, *, scopes=None, 219 | proxy_server=None, proxy_port=8080, proxy_username=None, proxy_password=None, 220 | requests_delay=200, raise_http_errors=True, request_retries=3, token_file_name=None): 221 | """ Creates an API connection object 222 | 223 | :param credentials: a tuple containing the credentials for this connection. 224 | This could be either (username, password) using basic authentication or (client_id, client_secret) using oauth. 225 | Generate client_id and client_secret in https://apps.dev.microsoft.com. 226 | :param scopes: oauth2: a list of scopes permissions to request access to 227 | :param proxy_server: the proxy server 228 | :param proxy_port: the proxy port, defaults to 8080 229 | :param proxy_username: the proxy username 230 | :param proxy_password: the proxy password 231 | :param requests_delay: number of miliseconds to wait between api calls 232 | The Api will respond with 429 Too many requests if more than 17 requests are made per second. 233 | Defaults to 200 miliseconds just in case more than 1 connection is making requests across multiple processes. 234 | :param raise_http_errors: If True Http 4xx and 5xx status codes will raise as exceptions 235 | :param request_retries: number of retries done when the server responds with 5xx error codes. 236 | :param token_file_name: custom token file name to be used when storing the token credentials. 237 | """ 238 | if not isinstance(credentials, tuple) or len(credentials) != 2 or (not credentials[0] and not credentials[1]): 239 | raise ValueError('Provide valid auth credentials') 240 | 241 | self.auth = credentials 242 | self.scopes = scopes 243 | self.store_token = True 244 | self.token_path = (Path() / token_file_name) if token_file_name else self._default_token_path 245 | self.token = None 246 | 247 | self.session = None # requests Oauth2Session object 248 | 249 | self.proxy = {} 250 | self.set_proxy(proxy_server, proxy_port, proxy_username, proxy_password) 251 | self.requests_delay = requests_delay or 0 252 | self.previous_request_at = None # store the time of the previous request 253 | self.raise_http_errors = raise_http_errors 254 | self.request_retries = request_retries 255 | 256 | self.naive_session = Session() # requests Session object 257 | self.naive_session.proxies = self.proxy 258 | 259 | if self.request_retries: 260 | retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries, 261 | backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST) 262 | adapter = HTTPAdapter(max_retries=retry) 263 | self.naive_session.mount('http://', adapter) 264 | self.naive_session.mount('https://', adapter) 265 | 266 | def set_proxy(self, proxy_server, proxy_port, proxy_username, proxy_password): 267 | """ Sets a proxy on the Session """ 268 | if proxy_server and proxy_port: 269 | if proxy_username and proxy_password: 270 | self.proxy = { 271 | "http": "http://{}:{}@{}:{}".format(proxy_username, proxy_password, proxy_server, proxy_port), 272 | "https": "https://{}:{}@{}:{}".format(proxy_username, proxy_password, proxy_server, proxy_port), 273 | } 274 | else: 275 | self.proxy = { 276 | "http": "http://{}:{}".format(proxy_server, proxy_port), 277 | "https": "https://{}:{}".format(proxy_server, proxy_port), 278 | } 279 | 280 | def check_token_file(self): 281 | """ Checks if the token file exists at the given position""" 282 | if self.token_path: 283 | path = Path(self.token_path) 284 | else: 285 | path = self._default_token_path 286 | 287 | return path.exists() 288 | 289 | def get_authorization_url(self, requested_scopes=None, redirect_uri=OAUTH_REDIRECT_URL): 290 | """ 291 | Inicialices the oauth authorization flow, getting the authorization url that the user must approve. 292 | This is a two step process, first call this function. Then get the url result from the user and then 293 | call 'request_token' to get and store the access token. 294 | """ 295 | 296 | client_id, client_secret = self.auth 297 | 298 | if requested_scopes: 299 | scopes = requested_scopes 300 | elif self.scopes is not None: 301 | scopes = self.scopes 302 | else: 303 | raise ValueError('Must provide at least one scope') 304 | 305 | self.session = oauth = OAuth2Session(client_id=client_id, redirect_uri=redirect_uri, scope=scopes) 306 | self.session.proxies = self.proxy 307 | if self.request_retries: 308 | retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries, 309 | backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST) 310 | adapter = HTTPAdapter(max_retries=retry) 311 | self.session.mount('http://', adapter) 312 | self.session.mount('https://', adapter) 313 | 314 | # TODO: access_type='offline' has no effect acording to documentation. This is done through scope 'offline_access'. 315 | auth_url, state = oauth.authorization_url(url=self._oauth2_authorize_url, access_type='offline') 316 | 317 | return auth_url 318 | 319 | def request_token(self, authorizated_url, store_token=True, token_path=None): 320 | """ 321 | Returns and saves the token with the authorizated_url provided by the user 322 | 323 | :param authorizated_url: url given by the authorization flow 324 | :param store_token: whether or not to store the token in file system, 325 | so u don't have to keep opening the auth link and authenticating every time 326 | :param token_path: full path to where the token should be saved to 327 | """ 328 | 329 | if self.session is None: 330 | raise RuntimeError("Fist call 'get_authorization_url' to generate a valid oauth object") 331 | 332 | client_id, client_secret = self.auth 333 | 334 | # Allow token scope to not match requested scope. (Other auth libraries allow 335 | # this, but Requests-OAuthlib raises exception on scope mismatch by default.) 336 | os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1' 337 | os.environ['OAUTHLIB_IGNORE_SCOPE_CHANGE'] = '1' 338 | 339 | try: 340 | self.token = self.session.fetch_token(token_url=self._oauth2_token_url, 341 | authorization_response=authorizated_url, 342 | client_id=client_id, 343 | client_secret=client_secret) 344 | except Exception as e: 345 | log.error('Unable to fetch auth token. Error: {}'.format(str(e))) 346 | return None 347 | 348 | if token_path: 349 | self.token_path = token_path 350 | self.store_token = store_token 351 | if self.store_token: 352 | self._save_token(self.token, self.token_path) 353 | 354 | return True 355 | 356 | def get_session(self, token_path=None): 357 | """ Create a requests Session object 358 | 359 | :param token_path: Only oauth: full path to where the token should be load from 360 | """ 361 | self.token = self.token or self._load_token(token_path or self.token_path) 362 | 363 | if self.token: 364 | client_id, _ = self.auth 365 | self.session = OAuth2Session(client_id=client_id, token=self.token) 366 | else: 367 | raise RuntimeError('No auth token found. Authentication Flow needed') 368 | 369 | self.session.proxies = self.proxy 370 | 371 | if self.request_retries: 372 | retry = Retry(total=self.request_retries, read=self.request_retries, connect=self.request_retries, 373 | backoff_factor=RETRIES_BACKOFF_FACTOR, status_forcelist=RETRIES_STATUS_LIST) 374 | adapter = HTTPAdapter(max_retries=retry) 375 | self.session.mount('http://', adapter) 376 | self.session.mount('https://', adapter) 377 | 378 | return self.session 379 | 380 | def refresh_token(self): 381 | """ Gets another token """ 382 | 383 | client_id, client_secret = self.auth 384 | self.token = token = self.session.refresh_token(self._oauth2_token_url, client_id=client_id, 385 | client_secret=client_secret) 386 | if self.store_token: 387 | self._save_token(token) 388 | 389 | def _check_delay(self): 390 | """ Checks if a delay is needed between requests and sleeps if True """ 391 | if self.previous_request_at: 392 | dif = round(time.time() - self.previous_request_at, 2) * 1000 # difference in miliseconds 393 | if dif < self.requests_delay: 394 | time.sleep((self.requests_delay - dif) / 1000) # sleep needs seconds 395 | self.previous_request_at = time.time() 396 | 397 | def _internal_request(self, request_obj, url, method, **kwargs): 398 | """ 399 | Internal handling of requests. Handles Exceptions. 400 | 401 | :param request_obj: a requests session. 402 | :param url: the url to be requested 403 | :param method: the method used on the request 404 | :param kwargs: any other payload to be passed to requests 405 | """ 406 | 407 | method = method.lower() 408 | assert method in self._allowed_methods, 'Method must be one of the allowed ones' 409 | 410 | if method == 'get': 411 | kwargs.setdefault('allow_redirects', True) 412 | elif method in ['post', 'put', 'patch']: 413 | if 'headers' not in kwargs: 414 | kwargs['headers'] = {} 415 | if kwargs.get('headers') is not None and kwargs['headers'].get('Content-type') is None: 416 | kwargs['headers']['Content-type'] = 'application/json' 417 | if 'data' in kwargs and kwargs['headers'].get('Content-type') == 'application/json': 418 | kwargs['data'] = json.dumps(kwargs['data']) # autoconvert to json 419 | 420 | request_done = False 421 | token_refreshed = False 422 | 423 | while not request_done: 424 | self._check_delay() # sleeps if needed 425 | try: 426 | log.info('Requesting ({}) URL: {}'.format(method.upper(), url)) 427 | log.info('Request parameters: {}'.format(kwargs)) 428 | response = request_obj.request(method, url, **kwargs) # auto_retry will occur inside this funcion call if enabled 429 | response.raise_for_status() # raise 4XX and 5XX error codes. 430 | log.info('Received response ({}) from URL {}'.format(response.status_code, response.url)) 431 | request_done = True 432 | return response 433 | except TokenExpiredError: 434 | # Token has expired refresh token and try again on the next loop 435 | if token_refreshed: 436 | # Refresh token done but still TolenExpiredError raise 437 | raise RuntimeError('Token Refresh Operation not working') 438 | log.info('Oauth Token is expired, fetching a new token') 439 | self.refresh_token() 440 | log.info('New oauth token fetched') 441 | token_refreshed = True 442 | except (ConnectionError, ProxyError, SSLError, Timeout) as e: 443 | # We couldn't connect to the target url, raise error 444 | log.debug('Connection Error calling: {}.{}'.format(url, 'Using proxy: {}'.format(self.proxy) if self.proxy else '')) 445 | raise e # re-raise exception 446 | except HTTPError as e: 447 | # Server response with 4XX or 5XX error status codes 448 | status_code = int(e.response.status_code / 100) 449 | if status_code == 4: 450 | # Client Error 451 | log.error('Client Error: {}'.format(str(e))) # logged as error. Could be a library error or Api changes 452 | else: 453 | # Server Error 454 | log.debug('Server Error: {}'.format(str(e))) 455 | if self.raise_http_errors: 456 | raise e 457 | else: 458 | return e.response 459 | except RequestException as e: 460 | # catch any other exception raised by requests 461 | log.debug('Request Exception: {}'.format(str(e))) 462 | raise e 463 | 464 | def naive_request(self, url, method, **kwargs): 465 | """ A naive request without any Authorization headers """ 466 | return self._internal_request(self.naive_session, url, method, **kwargs) 467 | 468 | def oauth_request(self, url, method, **kwargs): 469 | """ Makes a request to url using an oauth session """ 470 | 471 | # oauth authentication 472 | if not self.session: 473 | self.get_session() 474 | 475 | return self._internal_request(self.session, url, method, **kwargs) 476 | 477 | def get(self, url, params=None, **kwargs): 478 | """ Shorthand for self.request(url, 'get') """ 479 | return self.oauth_request(url, 'get', params=params, **kwargs) 480 | 481 | def post(self, url, data=None, **kwargs): 482 | """ Shorthand for self.request(url, 'post') """ 483 | return self.oauth_request(url, 'post', data=data, **kwargs) 484 | 485 | def put(self, url, data=None, **kwargs): 486 | """ Shorthand for self.request(url, 'put') """ 487 | return self.oauth_request(url, 'put', data=data, **kwargs) 488 | 489 | def patch(self, url, data=None, **kwargs): 490 | """ Shorthand for self.request(url, 'patch') """ 491 | return self.oauth_request(url, 'patch', data=data, **kwargs) 492 | 493 | def delete(self, url, **kwargs): 494 | """ Shorthand for self.request(url, 'delete') """ 495 | return self.oauth_request(url, 'delete', **kwargs) 496 | 497 | def _save_token(self, token, token_path=None): 498 | """ Save the specified token dictionary to a specified file path 499 | 500 | :param token: token dictionary returned by the oauth token request 501 | :param token_path: Path object to where the file is to be saved 502 | """ 503 | 504 | if not token_path: 505 | token_path = self.token_path or self._default_token_path 506 | else: 507 | if not isinstance(token_path, Path): 508 | raise ValueError('token_path must be a valid Path from pathlib') 509 | 510 | with token_path.open('w') as token_file: 511 | json.dump(token, token_file, indent=True) 512 | 513 | return True 514 | 515 | def _load_token(self, token_path=None): 516 | """ Load the specified token dictionary from specified file path 517 | 518 | :param token_path: Path object to the file with token information saved 519 | """ 520 | 521 | if not token_path: 522 | token_path = self.token_path or self._default_token_path 523 | else: 524 | if not isinstance(token_path, Path): 525 | raise ValueError('token_path must be a valid Path from pathlib') 526 | 527 | token = None 528 | if token_path.exists(): 529 | with token_path.open('r') as token_file: 530 | token = json.load(token_file) 531 | return token 532 | 533 | def _delete_token(self, token_path=None): 534 | """ Delete the specified token dictionary from specified file path 535 | 536 | :param token_path: Path object to where the token is saved 537 | """ 538 | 539 | if not token_path: 540 | token_path = self.token_path or self._default_token_path 541 | else: 542 | if not isinstance(token_path, Path): 543 | raise ValueError('token_path must be a valid Path from pathlib') 544 | 545 | if token_path.exists(): 546 | token_path.unlink() 547 | return True 548 | return False 549 | 550 | 551 | def oauth_authentication_flow(client_id, client_secret, scopes=None, protocol=None, **kwargs): 552 | """ 553 | A helper method to authenticate and get the oauth token 554 | :param client_id: the client_id 555 | :param client_secret: the client_secret 556 | :param scopes: a list of protocol user scopes to be converted by the protocol 557 | :param protocol: the protocol to be used. Defaults to MSGraphProtocol 558 | :param kwargs: other configuration to be passed to the Connection instance 559 | """ 560 | 561 | credentials = (client_id, client_secret) 562 | 563 | protocol = protocol or MSGraphProtocol 564 | 565 | if not isinstance(protocol, Protocol): 566 | protocol = protocol() 567 | 568 | con = Connection(credentials, scopes=protocol.get_scopes_for(scopes), **kwargs) 569 | 570 | consent_url = con.get_authorization_url() 571 | print('Visit the following url to give consent:') 572 | print(consent_url) 573 | 574 | token_url = input('Paste the authenticated url here: ') 575 | 576 | if token_url: 577 | result = con.request_token(token_url) 578 | if result: 579 | print('Authentication Flow Completed. Oauth Access Token Stored. You can now use the API.') 580 | else: 581 | print('Something go wrong. Please try again.') 582 | 583 | return bool(result) 584 | else: 585 | print('Authentication Flow aborted.') 586 | return False 587 | 588 | 589 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This project has been merged into the original one. Check [O365](https://github.com/O365/python-o365) repository. 2 | 3 | ## This fork is no longer maintained. Bugs, and improvements will occurr in [O365](https://github.com/O365/python-o365). 4 | 5 |
6 |
7 | 8 | ___ 9 | 10 | 11 | 12 | # pyo365 - Microsoft Graph and Office 365 API made easy 13 | This project aims is to make it easy to interact with Microsoft Graph and Office 365 Email, Contacts, Calendar, OneDrive, etc. 14 | 15 | This project is inspired on the super work done by [Toben Archer](https://github.com/Narcolapser) [Python-O365](https://github.com/Narcolapser/python-o365). 16 | The oauth part is based on the work done by [Royce Melborn](https://github.com/roycem90) which is now integrated with the original project. 17 | 18 | I just want to make this project different in almost every sense, and make it also more pythonic. 19 | So I ended up rewriting the whole project from scratch. 20 | 21 | The result is a package that provides a lot of the Microsoft Graph and Office 365 API capabilities. 22 | 23 | This is for example how you send a message: 24 | 25 | ```python 26 | from pyo365 import Account 27 | 28 | credentials = ('client_id', 'client_secret') 29 | 30 | account = Account(credentials) 31 | m = account.new_message() 32 | m.to.add('to_example@example.com') 33 | m.subject = 'Testing!' 34 | m.body = "George Best quote: I've stopped drinking, but only while I'm asleep." 35 | m.send() 36 | ``` 37 | 38 | 39 | **Python 3.4 is the minimum required**... I was very tempted to just go for 3.6 and use f-strings. Those are fantastic! 40 | 41 | This project was also a learning resource for me. This is a list of not so common python characteristics used in this project: 42 | - New unpacking technics: `def method(argument, *, with_name=None, **other_params):` 43 | - Enums: `from enum import Enum` 44 | - Factory paradigm 45 | - Package organization 46 | - Timezone conversion and timezone aware datetimes 47 | - Etc. (see the code!) 48 | 49 | > **This project is in early development.** Changes that can break your code may be commited. If you want to help please feel free to fork and make pull requests. 50 | 51 | 52 | What follows is kind of a wiki... but you will get more insights by looking at the code. 53 | 54 | ## Table of contents 55 | 56 | - [Install](#install) 57 | - [Protocols](#protocols) 58 | - [Authentication](#authentication) 59 | - [Account Class and Modularity](#account) 60 | - [MailBox](#mailbox) 61 | - [AddressBook](#addressbook) 62 | - [Calendar](#calendar) 63 | - [OneDrive](#onedrive) 64 | - [Sharepoint](#sharepoint) 65 | - [Utils](#utils) 66 | 67 | 68 | ## Install 69 | pyo365 is available on pypi.org. Simply run `pip install pyo365` to install it. 70 | 71 | Project dependencies installed by pip: 72 | - requests 73 | - requests-oauthlib 74 | - beatifulsoup4 75 | - stringcase 76 | - python-dateutil 77 | - tzlocal 78 | - pytz 79 | 80 | The first step to be able to work with this library is to register an application and retrieve the auth token. See [Authentication](#authentication). 81 | 82 | ## Protocols 83 | Protocols handles the aspects of comunications between different APIs. 84 | This project uses by default either the Office 365 APIs or Microsoft Graph APIs. 85 | But, you can use many other Microsoft APIs as long as you implement the protocol needed. 86 | 87 | You can use one or the other: 88 | 89 | - `MSGraphProtocol` to use the [Microsoft Graph API](https://developer.microsoft.com/en-us/graph/docs/concepts/overview) 90 | - `MSOffice365Protocol` to use the [Office 365 API](https://msdn.microsoft.com/en-us/office/office365/api/api-catalog) 91 | 92 | Both protocols are similar but consider the following: 93 | 94 | Reasons to use `MSGraphProtocol`: 95 | - It is the recommended Protocol by Microsoft. 96 | - It can access more resources over Office 365 (for example OneDrive) 97 | 98 | Reasons to use `MSOffice365Protocol`: 99 | - It can send emails with attachments up to 150 MB. MSGraph only allows 4MB on each request. 100 | 101 | The default protocol used by the `Account` Class is `MSGraphProtocol`. 102 | 103 | You can implement your own protocols by inheriting from `Protocol` to communicate with other Microsoft APIs. 104 | 105 | You can instantiate protocols like this: 106 | ```python 107 | from pyo365 import MSGraphProtocol 108 | 109 | # try the api version beta of the Microsoft Graph endpoint. 110 | protocol = MSGraphProtocol(api_version='beta') # MSGraphProtocol defaults to v1.0 api version 111 | ``` 112 | 113 | ##### Resources: 114 | Each API endpoint requires a resource. This usually defines the owner of the data. 115 | Every protocol defaults to resource 'ME'. 'ME' is the user which has given consent, but you can change this behaviour by providing a different default resource to the protocol constructor. 116 | 117 | For example when accesing a shared mailbox: 118 | 119 | 120 | ```python 121 | # ... 122 | account = Account(credentials=my_credentials, main_resource='shared_mailbox@example.com') 123 | # Any instance created using account will inherit the resource defined for account. 124 | ``` 125 | 126 | This can be done however at any point. For example at the protocol level: 127 | ```python 128 | # ... 129 | my_protocol = MSGraphProtocol(default_resource='shared_mailbox@example.com') 130 | 131 | account = Account(credentials=my_credentials, protocol=my_protocol) 132 | 133 | # now account is accesing the shared_mailbox@example.com in every api call. 134 | shared_mailbox_messages = account.mailbox().get_messages() 135 | ``` 136 | 137 | 138 | 139 | Instead of defining the resource used at the account or protocol level, you can provide it per use case as follows: 140 | ```python 141 | # ... 142 | account = Account(credentials=my_credentials) # account defaults to 'ME' resource 143 | 144 | mailbox = account.mailbox('shared_mailbox@example.com') # mailbox is using 'shared_mailbox@example.com' resource instead of 'ME' 145 | 146 | # or: 147 | 148 | message = Message(parent=account, main_resource='shared_mailbox@example.com') # message is using 'shared_mailbox@example.com' resource 149 | ``` 150 | 151 | Usually you will work with the default 'ME' resuorce, but you can also use one of the following: 152 | 153 | - **'me'**: the user which has given consent. the default for every protocol. 154 | - **'user:user@domain.com'**: a shared mailbox or a user account for which you have permissions. If you don't provide 'user:' will be infered anyways. 155 | - **'sharepoint:sharepoint-site-id'**: a sharepoint site id. 156 | - **'group:group-site-id'**: a office365 group id. 157 | 158 | ## Authentication 159 | You can only authenticate using oauth athentication as Microsoft deprecated basic oauth on November 1st 2018. 160 | 161 | - Oauth authentication: using an authentication token provided after user consent. 162 | 163 | The `Connection` Class handles the authentication. 164 | 165 | #### Oauth Authentication 166 | This section is explained using Microsoft Graph Protocol, almost the same applies to the Office 365 REST API. 167 | 168 | 169 | ##### Permissions and Scopes: 170 | When using oauth you create an application and allow some resources to be accesed and used by it's users. 171 | Then the user can request access to one or more of this resources by providing scopes to the oauth provider. 172 | 173 | For example your application can have Calendar.Read, Mail.ReadWrite and Mail.Send permissions, but the application can request access only to the Mail.ReadWrite and Mail.Send permission. 174 | This is done by providing scopes to the connection object like so: 175 | ```python 176 | from pyo365 import Connection 177 | 178 | credentials = ('client_id', 'client_secret') 179 | 180 | scopes = ['https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send'] 181 | 182 | con = Connection(credentials, scopes=scopes) 183 | ``` 184 | 185 | Scope implementation depends on the protocol used. So by using protocol data you can automatically set the scopes needed: 186 | 187 | You can get the same scopes as before using protocols like this: 188 | 189 | ```python 190 | protocol_graph = MSGraphProtocol() 191 | 192 | scopes_graph = protocol.get_scopes_for('message all') 193 | # scopes here are: ['https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send'] 194 | 195 | protocol_office = MSOffice365Protocol() 196 | 197 | scopes_office = protocol.get_scopes_for('message all') 198 | # scopes here are: ['https://outlook.office.com/Mail.ReadWrite', 'https://outlook.office.com/Mail.Send'] 199 | 200 | con = Connection(credentials, scopes=scopes_graph) 201 | ``` 202 | 203 | 204 | ##### Authentication Flow 205 | 1. To work with oauth you first need to register your application at [Microsoft Application Registration Portal](https://apps.dev.microsoft.com/). 206 | 207 | 1. Login at [Microsoft Application Registration Portal](https://apps.dev.microsoft.com/) 208 | 2. Create an app, note your app id (client_id) 209 | 3. Generate a new password (client_secret) under "Application Secrets" section 210 | 4. Under the "Platform" section, add a new Web platform and set "https://outlook.office365.com/owa/" as the redirect URL 211 | 5. Under "Microsoft Graph Permissions" section, add the delegated permissions you want (see scopes), as an example, to read and send emails use: 212 | 1. Mail.ReadWrite 213 | 2. Mail.Send 214 | 3. User.Read 215 | 216 | 2. Then you need to login for the first time to get the access token by consenting the application to access the resources it needs. 217 | 1. First get the authorization url. 218 | ```python 219 | url = account.connection.get_authorization_url() 220 | ``` 221 | 2. The user must visit this url and give consent to the application. When consent is given, the page will rediret to: "https://outlook.office365.com/owa/". 222 | 223 | Then the user must copy the resulting page url and give it to the connection object: 224 | 225 | ```python 226 | result_url = input('Paste the result url here...') 227 | 228 | account.connection.request_token(result_url) # This, if succesful, will store the token in a txt file on the user project folder. 229 | ``` 230 | 231 | Take care, the access token must remain protected from unauthorized users. 232 | 233 | 3. At this point you will have an access token that will provide valid credentials when using the api. If you change the scope requested, then the current token won't work, and you will need the user to give consent again on the application to gain access to the new scopes requested. 234 | 235 | The access token only lasts 60 minutes, but the app will automatically request new tokens through the refresh tokens, but note that a refresh token only lasts for 90 days. So you must use it before or you will need to request a new access token again (no new consent needed by the user, just a login). 236 | 237 | If your application needs to work for more than 90 days without user interaction and without interacting with the API, then you must implement a periodic call to `Connection.refresh_token` before the 90 days have passed. 238 | 239 | 240 | ##### Using pyo365 to authenticate 241 | 242 | You can manually authenticate by using a single `Connection` instance as described before or use the helper methods provided by the library. 243 | 244 | 1. `account.authenticate`: 245 | 246 | This is the preferred way for performing authentication. 247 | 248 | Create an `Account` instance and authenticate using the `authenticate` method: 249 | ```python 250 | from pyo365 import Account 251 | 252 | account = Account(credentials=('client_id', 'client_secret')) 253 | result = account.authenticate(scopes=['basic', 'message_all']) # request a token for this scopes 254 | 255 | # this will ask to visit the app consent screen where the user will be asked to give consent on the requested scopes. 256 | # then the user will have to provide the result url afeter consent. 257 | # if all goes as expected, result will be True and a token will be stored in the default location. 258 | ``` 259 | 260 | 2. `oauth_authentication_flow`: 261 | 262 | ```python 263 | from pyo365 import oauth_authentication_flow 264 | 265 | result = oauth_authentication_flow('client_id', 'client_secret', ['scopes_required']) 266 | ``` 267 | 268 | ## Account Class and Modularity 269 | Usually you will only need to work with the `Account` Class. This is a wrapper around all functionality. 270 | 271 | But you can also work only with the pieces you want. 272 | 273 | For example, instead of: 274 | ```python 275 | from pyo365 import Account 276 | 277 | account = Account(('client_id', 'client_secret')) 278 | message = account.new_message() 279 | # ... 280 | mailbox = account.mailbox() 281 | # ... 282 | ``` 283 | 284 | You can work only with the required pieces: 285 | 286 | ```python 287 | from pyo365 import Connection, MSGraphProtocol, Message, MailBox 288 | 289 | my_protocol = MSGraphProtocol() 290 | con = Connection(('client_id', 'client_secret')) 291 | 292 | message = Message(con=con, protocol=my_protocol) 293 | # ... 294 | mailbox = MailBox(con=con, protocol=my_protocol) 295 | message2 = Message(parent=mailbox) # message will inherit the connection and protocol from mailbox when using parent. 296 | # ... 297 | ``` 298 | 299 | It's also easy to implement a custom Class. 300 | 301 | Just Inherit from `ApiComponent`, define the endpoints, and use the connection to make requests. If needed also inherit from Protocol to handle different comunications aspects with the API server. 302 | 303 | ```python 304 | from pyo365.utils import ApiComponent 305 | 306 | class CustomClass(ApiComponent): 307 | _endpoints = {'my_url_key': '/customendpoint'} 308 | 309 | def __init__(self, *, parent=None, con=None, **kwargs): 310 | super().__init__(parent=parent, con=con, **kwargs) 311 | # ... 312 | 313 | def do_some_stuff(self): 314 | 315 | # self.build_url just merges the protocol service_url with the enpoint passed as a parameter 316 | # to change the service_url implement your own protocol inherinting from Protocol Class 317 | url = self.build_url(self._endpoints.get('my_url_key')) 318 | 319 | my_params = {'param1': 'param1'} 320 | 321 | response = self.con.get(url, params=my_params) # note the use of the connection here. 322 | 323 | # handle response and return to the user... 324 | ``` 325 | 326 | ## MailBox 327 | Mailbox groups the funcionality of both the messages and the email folders. 328 | 329 | ```python 330 | mailbox = account.mailbox() 331 | 332 | inbox = mailbox.inbox_folder() 333 | 334 | for message in inbox.get_messages(): 335 | print(message) 336 | 337 | sent_folder = mailbox.sent_folder() 338 | 339 | for message in sent_folder.get_messages(): 340 | print(message) 341 | 342 | m = mailbox.new_message() 343 | 344 | m.to.add('to_example@example.com') 345 | m.body = 'George Best quote: In 1969 I gave up women and alcohol - it was the worst 20 minutes of my life.' 346 | m.save_draft() 347 | ``` 348 | 349 | #### Email Folder 350 | Represents a `Folder` within your email mailbox. 351 | 352 | You can get any folder in your mailbox by requesting child folders or filtering by name. 353 | 354 | ```python 355 | mailbox = account.mailbox() 356 | 357 | archive = mailbox.get_folder(folder_name='archive') # get a folder with 'archive' name 358 | 359 | child_folders = archive.get_folders(25) # get at most 25 child folders of 'archive' folder 360 | 361 | for folder in child_folders: 362 | print(folder.name, folder.parent_id) 363 | 364 | new_folder = archive.create_child_folder('George Best Quotes') 365 | ``` 366 | 367 | #### Message 368 | An email object with all it's data and methods. 369 | 370 | Creating a draft message is as easy as this: 371 | ```python 372 | message = mailbox.new_message() 373 | message.to.add(['example1@example.com', 'example2@example.com']) 374 | message.sender.address = 'my_shared_account@example.com' # changing the from address 375 | message.body = 'George Best quote: I might go to Alcoholics Anonymous, but I think it would be difficult for me to remain anonymous' 376 | message.attachments.add('george_best_quotes.txt') 377 | message.save_draft() # save the message on the cloud as a draft in the drafts folder 378 | ``` 379 | 380 | Working with saved emails is also easy: 381 | ```python 382 | query = mailbox.new_query().on_attribute('subject').contains('george best') # see Query object in Utils 383 | messages = mailbox.get_messages(limit=25, query=query) 384 | 385 | message = messages[0] # get the first one 386 | 387 | message.mark_as_read() 388 | reply_msg = message.reply() 389 | 390 | if 'example@example.com' in reply_msg.to: # magic methods implemented 391 | reply_msg.body = 'George Best quote: I spent a lot of money on booze, birds and fast cars. The rest I just squandered.' 392 | else: 393 | reply_msg.body = 'George Best quote: I used to go missing a lot... Miss Canada, Miss United Kingdom, Miss World.' 394 | 395 | reply_msg.send() 396 | ``` 397 | 398 | ## AddressBook 399 | AddressBook groups the funcionality of both the Contact Folders and Contacts. Outlook Distribution Groups are not supported (By the Microsoft API's). 400 | 401 | #### Contact Folders 402 | Represents a Folder within your Contacts Section in Office 365. 403 | AddressBook class represents the parent folder (it's a folder itself). 404 | 405 | You can get any folder in your address book by requesting child folders or filtering by name. 406 | 407 | ```python 408 | address_book = account.address_book() 409 | 410 | contacts = address_book.get_contacts(limit=None) # get all the contacts in the Personal Contacts root folder 411 | 412 | work_contacts_folder = address_book.get_folder(folder_name='Work Contacts') # get a folder with 'Work Contacts' name 413 | 414 | message_to_all_contats_in_folder = work_contacts_folder.new_message() # creates a draft message with all the contacts as recipients 415 | 416 | message_to_all_contats_in_folder.subject = 'Hallo!' 417 | message_to_all_contats_in_folder.body = """ 418 | George Best quote: 419 | 420 | If you'd given me the choice of going out and beating four men and smashing a goal in 421 | from thirty yards against Liverpool or going to bed with Miss World, 422 | it would have been a difficult choice. Luckily, I had both. 423 | """ 424 | message_to_all_contats_in_folder.send() 425 | 426 | # querying folders is easy: 427 | child_folders = address_book.get_folders(25) # get at most 25 child folders 428 | 429 | for folder in child_folders: 430 | print(folder.name, folder.parent_id) 431 | 432 | # creating a contact folder: 433 | address_book.create_child_folder('new folder') 434 | ``` 435 | 436 | #### The Global Address List 437 | Office 365 API (Nor MS Graph API) has no concept such as the Outlook Global Address List. 438 | However you can use the [Users API](https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/users) to access all the users within your organization. 439 | 440 | Without admin consent you can only access a few properties of each user such as name and email and litte more. 441 | You can search by name or retrieve a contact specifying the complete email. 442 | 443 | - Basic Permision needed is Users.ReadBasic.All (limit info) 444 | - Full Permision is Users.Read.All but needs admin consent. 445 | 446 | To search the Global Address List (Users API): 447 | 448 | ```python 449 | global_address_list = account.address_book(address_book='gal') 450 | 451 | # start a new query: 452 | q = global_address_list.new_query('display_name') 453 | q.startswith('George Best') 454 | 455 | print(global_address_list.get_contacts(query=q)) 456 | ``` 457 | 458 | 459 | To retrieve a contact by it's email: 460 | 461 | ```python 462 | contact = global_address_list.get_contact_by_email('example@example.com') 463 | ``` 464 | 465 | #### Contacts 466 | Everything returned from an `AddressBook` instance is a `Contact` instance. 467 | Contacts have all the information stored as attributes 468 | 469 | Creating a contact from an `AddressBook`: 470 | 471 | ```python 472 | new_contact = address_book.new_contact() 473 | 474 | new_contact.name = 'George Best' 475 | new_contact.job_title = 'football player' 476 | new_contact.emails.add('george@best.com') 477 | 478 | new_contact.save() # saved on the cloud 479 | 480 | message = new_contact.new_message() # Bonus: send a message to this contact 481 | 482 | # ... 483 | 484 | new_contact.delete() # Bonus: deteled from the cloud 485 | ``` 486 | 487 | 488 | ## Calendar 489 | The calendar and events functionality is group in a `Schedule` object. 490 | 491 | A `Schedule` instance can list and create calendars. It can also list or create events on the default user calendar. 492 | To use other calendars use a `Calendar` instance. 493 | 494 | Working with the `Schedule` instance: 495 | ```python 496 | import datetime as dt 497 | 498 | # ... 499 | schedule = account.schedule() 500 | 501 | new_event = schedule.new_event() # creates a new event in the user default calendar 502 | new_event.subject = 'Recruit George Best!' 503 | new_event.location = 'England' 504 | 505 | # naive datetimes will automatically be converted to timezone aware datetime 506 | # objects using the local timezone detected or the protocol provided timezone 507 | 508 | new_event.start = dt.datetime(2018, 9, 5, 19, 45) 509 | # so new_event.start becomes: datetime.datetime(2018, 9, 5, 19, 45, tzinfo=) 510 | 511 | new_event.recurrence.set_daily(1, end=dt.datetime(2018, 9, 10)) 512 | new_event.remind_before_minutes = 45 513 | 514 | new_event.save() 515 | ``` 516 | 517 | Working with `Calendar` instances: 518 | ```python 519 | calendar = schedule.get_calendar(calendar_name='Birthdays') 520 | 521 | calendar.name = 'Football players birthdays' 522 | calendar.update() 523 | 524 | q = calendar.new_query('start').ge(dt.datetime(2018, 5, 20)).chain('and').on_attribute('end').le(dt.datetime(2018, 5, 24)) 525 | 526 | birthdays = calendar.get_events(query=q) 527 | 528 | for event in birthdays: 529 | if event.subject == 'George Best Birthday': 530 | # He died in 2005... but we celebrate anyway! 531 | event.accept("I'll attend!") # send a response accepting 532 | else: 533 | event.decline("No way I'm comming, I'll be in Spain", send_response=False) # decline the event but don't send a reponse to the organizer 534 | ``` 535 | 536 | ## OneDrive 537 | The `Storage` class handles all functionality around One Drive and Document Library Storage in Sharepoint. 538 | 539 | The `Storage` instance allows to retrieve `Drive` instances which handles all the Files and Folders from within the selected `Storage`. 540 | Usually you will only need to work with the default drive. But the `Storage` instances can handle multiple drives. 541 | 542 | 543 | A `Drive` will allow you to work with Folders and Files. 544 | 545 | ```python 546 | account = Account(credentials=my_credentials) 547 | 548 | storage = account.storage() # here we get the storage instance that handles all the storage options. 549 | 550 | # list all the drives: 551 | drives = storage.get_drives() 552 | 553 | # get the default drive 554 | my_drive = storage.get_default_drive() # or get_drive('drive-id') 555 | 556 | # get some folders: 557 | root_folder = my_drive.get_root_folder() 558 | attachments_folder = my_drive.get_special_folder('attachments') 559 | 560 | # iterate over the first 25 items on the root folder 561 | for item in root_folder.get_items(limit=25): 562 | if item.is_folder: 563 | print(item.get_items(2)) # print the first to element on this folder. 564 | elif item.is_file: 565 | if item.is_photo: 566 | print(item.camera_model) # print some metadata of this photo 567 | elif item.is_image: 568 | print(item.dimensione) # print the image dimensions 569 | else: 570 | # regular file: 571 | print(item.mime_type) # print the mime type 572 | ``` 573 | 574 | Both Files and Folders are DriveItems. Both Image and Photo are Files, but Photo is also an Image. All have some different methods and properties. 575 | Take care when using 'is_xxxx'. 576 | 577 | When coping a DriveItem the api can return a direct copy of the item or a pointer to a resource that will inform on the progress of the copy operation. 578 | 579 | ```python 580 | # copy a file to the documents special folder 581 | 582 | documents_folder = drive.get_special_folder('documents') 583 | 584 | files = drive.search('george best quotes', limit=1) 585 | 586 | if files: 587 | george_best_quotes = files[0] 588 | operation = george_best_quotes.copy(target=documents_folder) # operation here is an instance of CopyOperation 589 | 590 | # to check for the result just loop over check_status. 591 | # check_status is a generator that will yield a new status and progress until the file is finally copied 592 | for status, progress in operation.check_status(): # if it's an async operations, this will request to the api for the status in every loop 593 | print('{} - {}'.format(status, progress)) # prints 'in progress - 77.3' until finally completed: 'completed - 100.0' 594 | copied_item = operation.get_item() # the copy operation is completed so you can get the item. 595 | if copied_item: 596 | copied_item.delete() # ... oops! 597 | ``` 598 | 599 | You can also work with share permissions: 600 | 601 | ```python 602 | current_permisions = file.get_permissions() # get all the current permissions on this drive_item (some may be inherited) 603 | 604 | # share with link 605 | permission = file.share_with_link(share_type='edit') 606 | if permission: 607 | print(permission.share_link) # the link you can use to share this drive item 608 | # share with invite 609 | permission = file.share_with_invite(recipients='george_best@best.com', send_email=True, message='Greetings!!', share_type='edit') 610 | if permission: 611 | print(permission.granted_to) # the person you share this item with 612 | ``` 613 | 614 | You can also: 615 | ```python 616 | # download files: 617 | file.download(to_path='/quotes/') 618 | 619 | # upload files: 620 | 621 | # if the uploaded file is bigger than 4MB the file will be uploaded in chunks of 5 MB until completed. 622 | # this can take several requests and can be time consuming. 623 | uploaded_file = folder.upload_file(item='path_to_my_local_file') 624 | 625 | # restore versions: 626 | versiones = file.get_versions() 627 | for version in versions: 628 | if version.name == '2.0': 629 | version.restore() # restore the version 2.0 of this file 630 | 631 | # ... and much more ... 632 | ``` 633 | 634 | 635 | ## Sharepoint 636 | Work in progress 637 | 638 | 639 | ## Utils 640 | 641 | #### Pagination 642 | 643 | When using certain methods, it is possible that you request more items than the api can return in a single api call. 644 | In this case the Api, returns a "next link" url where you can pull more data. 645 | 646 | When this is the case, the methods in this library will return a `Pagination` object which abstracts all this into a single iterator. 647 | The pagination object will request "next links" as soon as they are needed. 648 | 649 | For example: 650 | 651 | ```python 652 | maibox = account.mailbox() 653 | 654 | messages = mailbox.get_messages(limit=1500) # the Office 365 and MS Graph API have a 999 items limit returned per api call. 655 | 656 | # Here messages is a Pagination instance. It's an Iterator so you can iterate over. 657 | 658 | # The first 999 iterations will be normal list iterations, returning one item at a time. 659 | # When the iterator reaches the 1000 item, the Pagination instance will call the api again requesting exactly 500 items 660 | # or the items specified in the batch parameter (see later). 661 | 662 | for message in messages: 663 | print(message.subject) 664 | ``` 665 | 666 | When using certain methods you will have the option to specify not only a limit option (the number of items to be returned) but a batch option. 667 | This option will indicate the method to request data to the api in batches until the limit is reached or the data consumed. 668 | This is usefull when you want to optimize memory or network latency. 669 | 670 | For example: 671 | 672 | ```python 673 | messages = mailbox.get_messages(limit=100, batch=25) 674 | 675 | # messages here is a Pagination instance 676 | # when iterating over it will call the api 4 times (each requesting 25 items). 677 | 678 | for message in messages: # 100 loops with 4 requests to the api server 679 | print(message.subject) 680 | ``` 681 | 682 | #### The Query helper 683 | 684 | When using the Office 365 API you can filter some fields. 685 | This filtering is tedious as is using [Open Data Protocol (OData)](http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html). 686 | 687 | Every `ApiComponent` (such as `MailBox`) implements a new_query method that will return a `Query` instance. 688 | This `Query` instance can handle the filtering (and sorting and selecting) very easily. 689 | 690 | For example: 691 | 692 | ```python 693 | query = mailbox.new_query() 694 | 695 | query = query.on_attribute('subject').contains('george best').chain('or').startswith('quotes') 696 | 697 | # 'created_date_time' will automatically be converted to the protocol casing. 698 | # For example when using MS Graph this will become 'createdDateTime'. 699 | 700 | query = query.chain('and').on_attribute('created_date_time').greater(datetime(2018, 3, 21)) 701 | 702 | print(query) 703 | 704 | # contains(subject, 'george best') or startswith(subject, 'quotes') and createdDateTime gt '2018-03-21T00:00:00Z' 705 | # note you can pass naive datetimes and those will be converted to you local timezone and then send to the api as UTC in iso8601 format 706 | 707 | # To use Query objetcs just pass it to the query parameter: 708 | filtered_messages = mailbox.get_messages(query=query) 709 | ``` 710 | 711 | You can also specify specific data to be retrieved with "select": 712 | 713 | ```python 714 | # select only some properties for the retrieved messages: 715 | query = mailbox.new_query().select('subject', 'to_recipients', 'created_date_time') 716 | 717 | messages_with_selected_properties = mailbox.get_messages(query=query) 718 | ``` 719 | 720 | #### Request Error Handling and Custom Errors 721 | 722 | Whenever a Request error raises, the connection object will raise an exception. 723 | Then the exception will be captured and logged it to the stdout with it's message, an return Falsy (None, False, [], etc...) 724 | 725 | HttpErrors 4xx (Bad Request) and 5xx (Internal Server Error) are considered exceptions and raised also by the connection (you can configure this on the connection). 726 | 727 | --------------------------------------------------------------------------------