From 663a66cfc987d6023694c81397804c37e9f8d11d Mon Sep 17 00:00:00 2001 From: Ihor Date: Thu, 14 Jan 2021 21:10:13 +0200 Subject: [PATCH 01/72] Updated handling of response code in core module According to issue #15 (https://github.com/swimlane/pyews/issues/15) unnecessary exception catching were removed, implemented instead correct handling of `ResponseCode`/`ErrorCode` and logging was enhanced a little bit. --- pyews/core.py | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/pyews/core.py b/pyews/core.py index c196f41..c039d35 100644 --- a/pyews/core.py +++ b/pyews/core.py @@ -44,15 +44,32 @@ def invoke(self, soap_body): auth=(self.userconfiguration.credentials.email_address, self.userconfiguration.credentials.password), verify=True ) + + __LOGGER__.debug('Response HTTP status code: %s', response.status_code) + __LOGGER__.debug('Response text: %s', response.text) + parsed_response = BeautifulSoup(response.content, 'xml') - try: - if parsed_response.find('ResponseCode').string == 'ErrorAccessDenied': - __LOGGER__.info('{}'.format(parsed_response.find('MessageText').string)) - if parsed_response.find('ResponseCode').string == 'NoError': - return parsed_response - except: - if parsed_response.find('ErrorCode').string == 'NoError': - return parsed_response + if not parsed_response.contents: + __LOGGER__.warning( + 'The server responded with empty content to POST-request ' + 'from {current}'.format(current=self.__class__.__name__)) + return + + response_code = getattr(parsed_response.find('ResponseCode'), 'string', None) + error_code = getattr(parsed_response.find('ErrorCode'), 'string', None) + + if 'NoError' in (response_code, error_code): + return parsed_response.find + elif 'ErrorAccessDenied' in (response_code, error_code): + __LOGGER__.warning( + 'The server responded with "ErrorAccessDenied" ' + 'response code to POST-request from {current}'.format( + current=self.__class__.__name__)) + else: + __LOGGER__.warning( + 'The server responded with unknown "ResponseCode" ' + 'and "ErrorCode" from {current}'.format( + current=self.__class__.__name__)) except requests.exceptions.HTTPError as errh: __LOGGER__.info("An Http Error occurred attempting to connect to {ep}:".format(ep=endpoint) + repr(errh)) except requests.exceptions.ConnectionError as errc: @@ -61,5 +78,5 @@ def invoke(self, soap_body): __LOGGER__.info("A Timeout Error occurred attempting to connect to {ep}:".format(ep=endpoint) + repr(errt)) except requests.exceptions.RequestException as err: __LOGGER__.info("An Unknown Error occurred attempting to connect to {ep}:".format(ep=endpoint) + repr(err)) - __LOGGER__.warning('Unable to parse response from {current}'.format(current=self.__class__.__name__)) - return None \ No newline at end of file + return None + From a3b0eef3d49fd4aaccb133ad2c4976e074b65d5a Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 5 Mar 2021 14:34:12 -0600 Subject: [PATCH 02/72] removing return statement method --- pyews/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyews/core.py b/pyews/core.py index c039d35..d7c6b53 100644 --- a/pyews/core.py +++ b/pyews/core.py @@ -59,7 +59,7 @@ def invoke(self, soap_body): error_code = getattr(parsed_response.find('ErrorCode'), 'string', None) if 'NoError' in (response_code, error_code): - return parsed_response.find + return parsed_response elif 'ErrorAccessDenied' in (response_code, error_code): __LOGGER__.warning( 'The server responded with "ErrorAccessDenied" ' From 64f6432595dd8574743f12643a7551f6ff08d581 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 5 Mar 2021 14:37:34 -0600 Subject: [PATCH 03/72] modified handling in resolvenames --- pyews/service/resolvenames.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyews/service/resolvenames.py b/pyews/service/resolvenames.py index 6a755ed..6f6466f 100644 --- a/pyews/service/resolvenames.py +++ b/pyews/service/resolvenames.py @@ -36,7 +36,7 @@ def __parse_response(self, value): value (str): The raw response from a SOAP request ''' return_dict = {} - if value.find('ResolveNamesResponse'): + if value and value.find('ResolveNamesResponse'): temp = value.find('ServerVersionInfo') return_dict['server_version_info'] = temp ver = "{major}.{minor}".format( From eb6ef67ba8d3dd9d3ef7060b15fd790e31580c6e Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 20 Apr 2021 11:28:07 -0500 Subject: [PATCH 04/72] bumped version to 3.0.0 --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index a07ceea..51b6f0a 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ def parse_requirements(requirement_file): setup( name='py-ews', - version='2.0.3', + version='3.0.0', packages=find_packages(exclude=['tests*']), license='MIT', description='A Python package to interact with both on-premises and Office 365 Exchange Web Services', @@ -17,5 +17,5 @@ def parse_requirements(requirement_file): url='https://github.com/swimlane/pyews', author='Swimlane', author_email='info@swimlane.com', - python_requires='>=2.6, !=3.0.*, !=3.1.*, !=3.2.*, <4', + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*, <4', ) \ No newline at end of file From 8a5a425e5c4c1146acdb898d260fce98a55af8c8 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:40:31 -0500 Subject: [PATCH 05/72] Updated documentation across entire project --- README.md | 164 +++++++----------- docs/configuration/autodiscover.md | 15 -- docs/configuration/credentials.md | 11 -- docs/configuration/root.md | 10 -- docs/configuration/userconfiguration.md | 11 -- docs/endpoint/adddelegate.md | 11 ++ docs/endpoint/convertid.md | 11 ++ docs/endpoint/createitem.md | 11 ++ docs/{services => endpoint}/deleteitem.md | 2 +- docs/endpoint/executesearch.md | 11 ++ docs/endpoint/expanddl.md | 11 ++ docs/endpoint/getattachment.md | 11 ++ docs/endpoint/gethiddeninboxrules.md | 11 ++ docs/{services => endpoint}/getinboxrules.md | 2 +- docs/endpoint/getitem.md | 11 ++ .../getsearchablemailboxes.md | 2 +- docs/endpoint/getserviceconfiguration.md | 11 ++ docs/endpoint/getusersettings.md | 11 ++ docs/{services => endpoint}/resolvenames.md | 2 +- docs/endpoint/root.md | 48 +++++ .../{services => endpoint}/searchmailboxes.md | 2 +- docs/endpoint/syncfolderhierarchy.md | 11 ++ docs/endpoint/syncfolderitems.md | 11 ++ .../add_additional_service_endpoints.md | 107 ++++++++++++ docs/service/autodiscover.md | 11 ++ docs/service/base.md | 13 ++ docs/service/operation.md | 11 ++ docs/service/root.md | 16 ++ .../add_additional_service_endpoints.md | 58 ------- docs/services/findhiddeninboxrules.md | 11 -- docs/services/root.md | 18 -- docs/utils/exceptions.md | 37 +--- docs/utils/exchangeversion.md | 2 +- docs/utils/logger.md | 2 +- 34 files changed, 410 insertions(+), 277 deletions(-) delete mode 100644 docs/configuration/autodiscover.md delete mode 100644 docs/configuration/credentials.md delete mode 100644 docs/configuration/root.md delete mode 100644 docs/configuration/userconfiguration.md create mode 100644 docs/endpoint/adddelegate.md create mode 100644 docs/endpoint/convertid.md create mode 100644 docs/endpoint/createitem.md rename docs/{services => endpoint}/deleteitem.md (82%) create mode 100644 docs/endpoint/executesearch.md create mode 100644 docs/endpoint/expanddl.md create mode 100644 docs/endpoint/getattachment.md create mode 100644 docs/endpoint/gethiddeninboxrules.md rename docs/{services => endpoint}/getinboxrules.md (80%) create mode 100644 docs/endpoint/getitem.md rename docs/{services => endpoint}/getsearchablemailboxes.md (78%) create mode 100644 docs/endpoint/getserviceconfiguration.md create mode 100644 docs/endpoint/getusersettings.md rename docs/{services => endpoint}/resolvenames.md (81%) create mode 100644 docs/endpoint/root.md rename docs/{services => endpoint}/searchmailboxes.md (78%) create mode 100644 docs/endpoint/syncfolderhierarchy.md create mode 100644 docs/endpoint/syncfolderitems.md create mode 100644 docs/service/add_additional_service_endpoints.md create mode 100644 docs/service/autodiscover.md create mode 100644 docs/service/base.md create mode 100644 docs/service/operation.md create mode 100644 docs/service/root.md delete mode 100644 docs/services/add_additional_service_endpoints.md delete mode 100644 docs/services/findhiddeninboxrules.md delete mode 100644 docs/services/root.md diff --git a/README.md b/README.md index e5df5e1..14ca99a 100644 --- a/README.md +++ b/README.md @@ -33,15 +33,27 @@ * Delete email items from mailboxes in your Exchange environment * Retrieve mailbox inbox rules for a specific account * Find additional hidden inbox rules for a specified account +* Plus more supported endpoints Currently this package supports the following endpoint's: -* [DeleteItem](docs/services/deleteitem.md) -* [GetInboxRules](docs/services/getinboxrules.md) -* [FindHiddenInboxRules](docs/services/findhiddeninboxrules.md) -* [GetSearchableMailboxes](docs/services/getsearchablemailboxes.md) -* [ResolveNames](docs/services/resolvenames.md) -* [SearchMailboxes](docs/services/searchmailboxes.md) +* [AddDelegate](docs/endpoint/adddelegate.md) +* [ConvertId](docs/endpoint/convertid.md) +* [CreateItem](docs/endpoint/createitem.md) +* [DeleteItem](docs/endpoint/deleteitem.md) +* [ExecuteSearch](docs/endpoint/executesearch.md) +* [ExpandDL](docs/endpoint/expanddl.md) +* [GetAttachment](docs/endpoint/getattachment.md) +* [GetHiddenInboxRules](docs/endpoint/gethiddeninboxrules.md) +* [GetInboxRules](docs/endpoint/getinboxrules.md) +* [GetItem](docs/endpoint/getitem.md) +* [GetSearchableMailboxes](docs/endpoint/getsearchablemailboxes.md) +* [GetServiceConfiguration](docs/endpoint/getserviceconfiguration.md) +* [GetUserSettings](docs/endpoint/getusersettings.md) +* [ResolveNames](docs/endpoint/resolvenames.md) +* [SearchMailboxes](docs/endpoint/searchmailboxes.md) +* [SyncFolderHierarchy](docs/endpoint/syncfolderhierarchy.md) +* [SyncFolderItems](docs/endpoint/syncfolderitems.md) ## Installation @@ -58,122 +70,82 @@ pip install py-ews pip install py-ews ``` -## Usage example +## Creating EWS Object -The first step in using **py-ews** is that you need to create a [UserConfiguration](docs/configuration/userconfiguration.md) object. Think of this as all the connection information for Exchange Web Services. An example of creating a [UserConfiguration](docs/configuration/userconfiguration.md) using Office 365 Autodiscover is: +When instantiating the `EWS` class you will need to provide credentials which will be used for all methods within the EWS class. ```python -from pyews import UserConfiguration +from pyews import EWS -userconfig = UserConfiguration( +ews = EWS( 'myaccount@company.com', 'Password1234' ) ``` - -If you would like to use an alternative [Autodiscover](docs/configuration/autodiscover.md) endpoint (or any alternative endpoint) then please provide one using the `endpoint` named parameter: +If you would like to use an alternative EWS URL then provide one using the `ews_url` parameter when instantiating the EWS class. ```python -from pyews import UserConfiguration +from pyews import EWS -userconfig = UserConfiguration( +ews = EWS( 'myaccount@company.com', 'Password1234', - endpoint='https://outlook.office365.com/autodiscover/autodiscover.svc' + ews_url='https://outlook.office365.com/autodiscover/autodiscover.svc' ) ``` -For more information about creating a [UserConfiguration](docs/configuration/userconfiguration.md) object, please see the full documentation. - -Now that you have a [UserConfiguration](docs/configuration/userconfiguration.md) object, we can now use any of the available service endpoints. This example will demonstrate how you can identify which mailboxes you have access to by using the [GetSearchableMailboxes](docs/services/getsearchablemailboxes.md) EWS endpoint. - -Once you have identified a list of mailbox reference ids, then you can begin searching all of those mailboxes by using the [SearchMailboxes](docs/services/searchmailboxes.md) EWS endpoint. - -The returned results will then be deleted (moved to Deleted Items folder) from Exchange using the [DeleteItem](docs/services/deleteitem.md) EWS endpoint. - -```python -from pyews import UserConfiguration -from pyews import GetSearchableMailboxes -from pyews import SearchMailboxes -from pyews import DeleteItem +If you would like to specify a specific version of Exchange to use, you can provide that using the `exchange_version` parameter. By default `pyews` will attempt all Exchange versions as well as multiple static and generated EWS URLs. -userconfig = UserConfiguration( - 'myaccount@company.com', - 'Password1234' -) +## Using Provided Methods -# get searchable mailboxes based on your accounts permissions -referenceid_list = [] -for mailbox in GetSearchableMailboxes(userconfig).run(): - referenceid_list.append(mailbox['reference_id']) - -# let's search all the referenceid_list items -messages_found = [] -for search in SearchMailboxes(userconfig).run('subject:account', referenceid_list): - messages_found.append(search['id']) - # we can print the results first if we want - print(search['subject']) - print(search['id']) - print(search['sender']) - print(search['to_recipients']) - print(search['created_time']) - print(search['received_time']) - #etc. - -# if we wanted to now delete a specific message then we would call the DeleteItem -# class like this but we can also pass in the entire messages_found list -deleted_message_response = DeleteItem(userconfig).run(messages_found[2]) - -print(deleted_message_response) -``` +Once you have instantiated the EWS class with your credentials, you will have access to pre-exposed methods for each endpoint. These methods are: -The following is an example of the output returned when calling the above code: - -```text -YOUR ACCOUNT IS ABOUT TO EXPIRE! UPGRADE NOW!!! -AAMkAGZjOTlkOWExLTM2MDEtNGI3MS0.............. -Josh Rickard -Research -2019-02-28T18:28:36Z -2019-02-28T18:28:36Z -Upgrade Your Account! -AAMkADAyNTZhNmMyLWNmZTctNDIyZC0.............. -Josh Rickard -Josh Rickard -2019-01-24T18:41:11Z -2019-01-24T18:41:11Z -New or modified user account information -AAMkAGZjOTlkOWExLTM2MDEtNGI3MS04.............. -Microsoft Online Services Team -Research -2019-01-24T18:38:06Z -2019-01-24T18:38:06Z -[{'MessageText': 'Succesfull'}] -``` +* get_service_configuration +* get_searchable_mailboxes +* get_user_settings +* resolve_names +* execute_ews_search +* execute_outlook_search +* get_inbox_rules +* get_hidden_inbox_rules +* get_item +* sync_folder_hierarchy +* sync_folder_items +* create_item +## Importing Endpoints -**For more examples and usage, please refer to the individual class documentation** +If you would like to write your own methods, you can import each endpoint directly into your script. -* [Services](docs/services/root.md) -* [Configuration](docs/configuration/root.md) +This example will demonstrate how you can identify which mailboxes you have access to by using the [GetSearchableMailboxes](docs/endpoint/getsearchablemailboxes.md) EWS endpoint. -## Development setup +```python +from pyews import Core +from pyews.endpoint import GetSearchableMailboxes -I have provided a [Dockerfile](https://github.com/swimlane/pyews/blob/master/Dockerfile) with all the dependencies and it is currently calling [test.py](https://github.com/swimlane/pyews/blob/master/Dockerfilebin\pyews_test.py). If you want to test new features, I recommend that you use this [Dockerfile](https://github.com/swimlane/pyews/blob/master/Dockerfile). You can call the following to build a new container, but keep the dependencies unless they have changed in your requirements.txt or any other changes to the [Dockerfile](https://github.com/swimlane/pyews/blob/master/Dockerfile). +Core.exchange_versions = 'Exchange2016' +Core.credentials = ('mymailbox@mailbox.com', 'some password') +Core.endpoints = 'mailbox.com' +reference_id_list = [] +for mailbox in GetSearchableMailboxes().run(): + reference_id_list.append(mailbox.get('reference_id')) + print(mailbox) ``` -docker build --force-rm -t pyews . -``` -To run the container, use the following: +Once you have identified a list of mailbox reference ids, then you can begin searching all of those mailboxes by using the [SearchMailboxes](docs/endpoint/searchmailboxes.md) EWS endpoint. + +```python +from pyews.endpoint import SearchMailboxes -``` -docker run pyews +for search_item in SearchMailboxes('phish', reference_id_list).run(): + print(search_item) ``` -I am new to Unit Testing, but I am working on that as time permits. If you would like to help, I wouldn't be sad about it. :) +**For more examples and usage, please refer to the individual class documentation** +* [Endpoint](docs/endpoint/root.md) ## Release History @@ -181,6 +153,8 @@ I am new to Unit Testing, but I am working on that as time permits. If you woul * Initial release of py-ews and it is still considered a work in progress * 2.0.0 * Revamped logic and overhauled all endpoints and classes +* 3.0.0 + * Refactored completely - this can be considered a new version ## Meta @@ -196,13 +170,3 @@ Distributed under the MIT license. See ``LICENSE`` for more information. 3. Commit your changes (`git commit -am 'Add some fooBar'`) 4. Push to the branch (`git push origin feature/fooBar`) 5. Create a new Pull Request - -```eval_rst -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - services/root - configuration/root - utils/root -``` diff --git a/docs/configuration/autodiscover.md b/docs/configuration/autodiscover.md deleted file mode 100644 index fed2afd..0000000 --- a/docs/configuration/autodiscover.md +++ /dev/null @@ -1,15 +0,0 @@ -# Autodiscover - -Exchange Web Services can be accessed using a direct URL or using Autodiscover. If your on-premises Exchange infrastructure is fairly large, you will need to ensure you are connecting to the correct server. Typically you would provide a specific address that you would want to communicate with, but with Exchange they offer an Autodiscover service. - -Autodiscover is exactly what it sounds like, it will auto discover which server you should be communicating with all through a central endpoint/url. - -This documentation provides details about the Autodiscover class within the `pyews` package. If you would like to find out more information about Exchange's Autodiscover service, please refer to Microsoft's documentation. - -This class is used in the `UserConfiguration` class when you decide to use Exchange Web Services Autodiscover service endpoints. - -```eval_rst -.. automodule:: pyews.Autodiscover - :members: - :undoc-members: -``` \ No newline at end of file diff --git a/docs/configuration/credentials.md b/docs/configuration/credentials.md deleted file mode 100644 index 1baa19b..0000000 --- a/docs/configuration/credentials.md +++ /dev/null @@ -1,11 +0,0 @@ -# Credentials - -This documentation provides details about the Credentials class within the `pyews` package. - -This class is used to store authentication credentials and is used when calling all service endpoints. - -```eval_rst -.. automodule:: pyews.Credentials - :members: - :undoc-members: -``` \ No newline at end of file diff --git a/docs/configuration/root.md b/docs/configuration/root.md deleted file mode 100644 index 20d45f7..0000000 --- a/docs/configuration/root.md +++ /dev/null @@ -1,10 +0,0 @@ -# Configuration Options - -```eval_rst -.. toctree:: - :maxdepth: 2 - - autodiscover - userconfiguration - credentials -``` \ No newline at end of file diff --git a/docs/configuration/userconfiguration.md b/docs/configuration/userconfiguration.md deleted file mode 100644 index 8acc5c9..0000000 --- a/docs/configuration/userconfiguration.md +++ /dev/null @@ -1,11 +0,0 @@ -# UserConfiguration - -This documentation provides details about the UserConfiguration class within the `pyews` package. - -This class is used in the `ServiceEndpoint` parent class and all inherited child classes. - -```eval_rst -.. automodule:: pyews.UserConfiguration - :members: - :undoc-members: -``` \ No newline at end of file diff --git a/docs/endpoint/adddelegate.md b/docs/endpoint/adddelegate.md new file mode 100644 index 0000000..4f40f1d --- /dev/null +++ b/docs/endpoint/adddelegate.md @@ -0,0 +1,11 @@ +# AddDelegate + +This documentation provides details about the AddDelegate class within the `pyews` package. + +This class is used to add delegates to a provided mailbox. + +```eval_rst +.. autoclass:: pyews.endpoint.adddelegate.AddDelegate + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/endpoint/convertid.md b/docs/endpoint/convertid.md new file mode 100644 index 0000000..227ab2a --- /dev/null +++ b/docs/endpoint/convertid.md @@ -0,0 +1,11 @@ +# ConvertId + +This documentation provides details about the ConvertId class within the `pyews` package. + +This class is used to convert item and folder ids from one format to another. + +```eval_rst +.. autoclass:: pyews.endpoint.convertid.ConvertId + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/endpoint/createitem.md b/docs/endpoint/createitem.md new file mode 100644 index 0000000..0d734b4 --- /dev/null +++ b/docs/endpoint/createitem.md @@ -0,0 +1,11 @@ +# CreateItem + +This documentation provides details about the CreateItem class within the `pyews` package. + +This class is used to create items in a users mailbox. + +```eval_rst +.. autoclass:: pyews.endpoint.createitem.CreateItem + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/services/deleteitem.md b/docs/endpoint/deleteitem.md similarity index 82% rename from docs/services/deleteitem.md rename to docs/endpoint/deleteitem.md index 17673f6..0cb6a50 100644 --- a/docs/services/deleteitem.md +++ b/docs/endpoint/deleteitem.md @@ -5,7 +5,7 @@ This documentation provides details about the DeleteItem class within the `pyews This class is used to delete items (typically mailbox items) from the specified user mailbox. ```eval_rst -.. automodule:: pyews.DeleteItem +.. autoclass:: pyews.endpoint.deleteitem.DeleteItem :members: :undoc-members: ``` \ No newline at end of file diff --git a/docs/endpoint/executesearch.md b/docs/endpoint/executesearch.md new file mode 100644 index 0000000..8f1a947 --- /dev/null +++ b/docs/endpoint/executesearch.md @@ -0,0 +1,11 @@ +# ExecuteSearch + +This documentation provides details about the ExecuteSearch class within the `pyews` package. + +This class is used to execute a search as the authenticated user but acting like an Outlook mail client. + +```eval_rst +.. autoclass:: pyews.endpoint.executesearch.ExecuteSearch + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/endpoint/expanddl.md b/docs/endpoint/expanddl.md new file mode 100644 index 0000000..0a712b2 --- /dev/null +++ b/docs/endpoint/expanddl.md @@ -0,0 +1,11 @@ +# ExpandDL + +This documentation provides details about the ExpandDL class within the `pyews` package. + +This class is used to expand a provided distrubtion list / group. + +```eval_rst +.. autoclass:: pyews.endpoint.expanddl.ExpandDL + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/endpoint/getattachment.md b/docs/endpoint/getattachment.md new file mode 100644 index 0000000..83b5a51 --- /dev/null +++ b/docs/endpoint/getattachment.md @@ -0,0 +1,11 @@ +# GetAttachment + +This documentation provides details about the GetAttachment class within the `pyews` package. + +This class is used to retrieve an attachment object using a provided attachment id. + +```eval_rst +.. autoclass:: pyews.endpoint.getattachment.GetAttachment + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/endpoint/gethiddeninboxrules.md b/docs/endpoint/gethiddeninboxrules.md new file mode 100644 index 0000000..a3562c8 --- /dev/null +++ b/docs/endpoint/gethiddeninboxrules.md @@ -0,0 +1,11 @@ +# GetHiddenInboxRules + +This documentation provides details about the GetHiddenInboxRules class within the `pyews` package. + +This class is used to get hidden mailbox rules from the specified user mailbox. + +```eval_rst +.. autoclass:: pyews.endpoint.gethiddeninboxrules.GetHiddenInboxRules + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/services/getinboxrules.md b/docs/endpoint/getinboxrules.md similarity index 80% rename from docs/services/getinboxrules.md rename to docs/endpoint/getinboxrules.md index e6596b8..005b97e 100644 --- a/docs/services/getinboxrules.md +++ b/docs/endpoint/getinboxrules.md @@ -5,7 +5,7 @@ This documentation provides details about the GetInboxRules class within the `py This class is used to get mailbox rules from the specified user mailbox. ```eval_rst -.. automodule:: pyews.GetInboxRules +.. autoclass:: pyews.endpoint.getinboxrules.GetInboxRules :members: :undoc-members: ``` \ No newline at end of file diff --git a/docs/endpoint/getitem.md b/docs/endpoint/getitem.md new file mode 100644 index 0000000..8658067 --- /dev/null +++ b/docs/endpoint/getitem.md @@ -0,0 +1,11 @@ +# GetItem + +This documentation provides details about the GetItem class within the `pyews` package. + +This class is used to retrieve an item from a mailbox. + +```eval_rst +.. autoclass:: pyews.endpoint.getitem.GetItem + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/services/getsearchablemailboxes.md b/docs/endpoint/getsearchablemailboxes.md similarity index 78% rename from docs/services/getsearchablemailboxes.md rename to docs/endpoint/getsearchablemailboxes.md index 2101913..d99d551 100644 --- a/docs/services/getsearchablemailboxes.md +++ b/docs/endpoint/getsearchablemailboxes.md @@ -5,7 +5,7 @@ This documentation provides details about the GetSearchableMailboxes class withi This class is used to retrieve all searchable mailboxes that the user configuration has access to search. ```eval_rst -.. automodule:: pyews.GetSearchableMailboxes +.. autoclass:: pyews.endpoint.getsearchablemailboxes.GetSearchableMailboxes :members: :undoc-members: ``` \ No newline at end of file diff --git a/docs/endpoint/getserviceconfiguration.md b/docs/endpoint/getserviceconfiguration.md new file mode 100644 index 0000000..a088394 --- /dev/null +++ b/docs/endpoint/getserviceconfiguration.md @@ -0,0 +1,11 @@ +# GetServiceConfiguration + +This documentation provides details about the GetServiceConfiguration class within the `pyews` package. + +This class is used to retrieve one or more service configurations. + +```eval_rst +.. autoclass:: pyews.endpoint.getserviceconfiguration.GetServiceConfiguration + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/endpoint/getusersettings.md b/docs/endpoint/getusersettings.md new file mode 100644 index 0000000..2619cea --- /dev/null +++ b/docs/endpoint/getusersettings.md @@ -0,0 +1,11 @@ +# GetUserSettings + +This documentation provides details about the GetUserSettings class within the `pyews` package. + +This class is used to retrieve user settings. + +```eval_rst +.. autoclass:: pyews.endpoint.getusersettings.GetUserSettings + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/services/resolvenames.md b/docs/endpoint/resolvenames.md similarity index 81% rename from docs/services/resolvenames.md rename to docs/endpoint/resolvenames.md index ab003f7..3193228 100644 --- a/docs/services/resolvenames.md +++ b/docs/endpoint/resolvenames.md @@ -5,7 +5,7 @@ This documentation provides details about the ResolveNames class within the `pye This class is used to resolve a mailbox email address to retrieve details about a user. ```eval_rst -.. automodule:: pyews.ResolveNames +.. autoclass:: pyews.endpoint.resolvenames.ResolveNames :members: :undoc-members: ``` \ No newline at end of file diff --git a/docs/endpoint/root.md b/docs/endpoint/root.md new file mode 100644 index 0000000..1014736 --- /dev/null +++ b/docs/endpoint/root.md @@ -0,0 +1,48 @@ +# Endpoints + +This documentation provides details about the available Exchange Web Services endpoints within the `pyews` package. + +All endpoints inherit from either the Autodiscover or Operation classes. These classes make extensibility much easier and allows users of this package to define new endpoints easily. + + +* [AddDelegate](adddelegate.md) +* [ConvertId](convertid.md) +* [CreateItem](createitem.md) +* [DeleteItem](deleteitem.md) +* [ExecuteSearch](executesearch.md) +* [ExpandDL](expanddl.md) +* [GetAttachment](getattachment.md) +* [GetHiddenInboxRules](gethiddeninboxrules.md) +* [GetInboxRules](getinboxrules.md) +* [GetItem](getitem.md) +* [GetSearchableMailboxes](getsearchablemailboxes.md) +* [GetServiceConfiguration](getserviceconfiguration.md) +* [GetUserSettings](getusersettings.md) +* [ResolveNames](resolvenames.md) +* [SearchMailboxes](searchmailboxes.md) +* [SyncFolderHierarchy](syncfolderhierarchy.md) +* [SyncFolderItems](syncfolderitems.md) + + +```eval_rst +.. toctree:: + :maxdepth: 2 + + adddelegate + convertid + createitem + deleteitem + executesearch + expanddl + getattachment + gethiddeninboxrules + getinboxrules + getitem + getsearchablemailboxes + getserviceconfiguration + getusersettings + resolvenames + searchmailboxes + syncfolderhierarchy + syncfolderitems +``` diff --git a/docs/services/searchmailboxes.md b/docs/endpoint/searchmailboxes.md similarity index 78% rename from docs/services/searchmailboxes.md rename to docs/endpoint/searchmailboxes.md index df31217..1ae6595 100644 --- a/docs/services/searchmailboxes.md +++ b/docs/endpoint/searchmailboxes.md @@ -5,7 +5,7 @@ This documentation provides details about the SearchMailboxes class within the ` This class is used to search users mailboxes for a specific item. ```eval_rst -.. automodule:: pyews.SearchMailboxes +.. autoclass:: pyews.endpoint.searchmailboxes.SearchMailboxes :members: :undoc-members: ``` \ No newline at end of file diff --git a/docs/endpoint/syncfolderhierarchy.md b/docs/endpoint/syncfolderhierarchy.md new file mode 100644 index 0000000..1c89ab3 --- /dev/null +++ b/docs/endpoint/syncfolderhierarchy.md @@ -0,0 +1,11 @@ +# SyncFolderHierarchy + +This documentation provides details about the SyncFolderHierarchy class within the `pyews` package. + +This class is used to retrieve a users mailbox folder hierarchy. + +```eval_rst +.. autoclass:: pyews.endpoint.syncfolderhierarchy.SyncFolderHierarchy + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/endpoint/syncfolderitems.md b/docs/endpoint/syncfolderitems.md new file mode 100644 index 0000000..22451d9 --- /dev/null +++ b/docs/endpoint/syncfolderitems.md @@ -0,0 +1,11 @@ +# SyncFolderItems + +This documentation provides details about the SyncFolderItems class within the `pyews` package. + +This class is used to retrieve individual items from a users mailbox. + +```eval_rst +.. autoclass:: pyews.endpoint.syncfolderitems.SyncFolderItems + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/service/add_additional_service_endpoints.md b/docs/service/add_additional_service_endpoints.md new file mode 100644 index 0000000..0772b41 --- /dev/null +++ b/docs/service/add_additional_service_endpoints.md @@ -0,0 +1,107 @@ +# Adding Additional EWS Service Endpoints + +As I stated above I will continue to add additional EWS Service Endpoints, but I wanted to share documentation on how to add your own support for additional service endpoints. + +All endpoints inherit from either :doc:`autodiscover` or :doc:`operation` classes. In order to define a new endpoint you will need to import one of these classes. + +```python +from pyews.service import Operation +``` + +Once you have imported the appropriate class (typically this will be Operation) you will then create a new class and inherit from it. In this example I will demonstrate how to build a endpoint for the [`GetAppManifests`](https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getappmanifests-operation) operation: + +```python +from pyews.service import Operation + +class GetAppManifests(Operation): + + def soap(self): + pass +``` + +In order to inherit from `Operation` you must define the class name (which should be the name of the EWS operation) and a single method called `soap`. + +The `soap` method will return the `Body` of a SOAP request using the provided elements. + +* M_NAMESPACE +* T_NAMESPACE +* A_NAMESPACE +* WSA_NAMESPACE + +By far the most common namespaces you will use wil be the `M_NAMESPACE` and `T_NAMESPACE` properties. + +If we look at the example SOAP requst on this [page](https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getappmanifests-operation) you will see this: + +``` + + + + + en-US + + + + + + + 1.1 + 1.1 + + + +``` + +We will only worry about this portion of the XML SOAP request. All other data is managed by the inherited classes: + +``` + + 1.1 + 1.1 + +``` + +To define this using the provided namespaces will use the provided namespace attributes (e.g. `m:` or `t:`) and build our return object. + +This means that our `GetAppManifests` class and `soap` method will look like this: + +```python +from pyews.service import Operation + +class GetAppManifests(Operation): + + def soap(self): + return self.M_NAMESPACE.GetAppManifests( + self.M_NAMESPACE.ApiVersionSupported('1.1'), + self.M_NAMESPACE.SchemaVersionSupported('1.1') + ) +``` + +That's it! Seriously, pretty easy huh? + +## Additional details + +If you see a SOAP request element on Microsoft's site that looks like this: + +``` + +``` + +Then using our namespaces you would write this as: + +```python +self.T_NAMESPACE.AlternateId(Format="EwsId", Id="AAMkAGZhN2IxYTA0LWNiNzItN=", Mailbox="user1@example.com") +``` + +## Running your class + +Now that we have our newly defined endpoint we can instantiate it and then just call the `run` method. + +```python +from getappmanifests import GetAppManifests + +print(GetAppManifests().run()) +``` + +And we're done! I hope this helps and if you have any feedback or questions please open a pull requst or an issue. \ No newline at end of file diff --git a/docs/service/autodiscover.md b/docs/service/autodiscover.md new file mode 100644 index 0000000..2976c10 --- /dev/null +++ b/docs/service/autodiscover.md @@ -0,0 +1,11 @@ +# Autodiscover + +The Autodiscover class is used by any endpoints that want to use the Microsoft Exchange Autodiscover service. + +This class defines the SOAP Envelope, namespaces, headers, and body definitions for any class that inherits from it. + +```eval_rst +.. automodule:: pyews.service.autodiscover.Autodiscover + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/service/base.md b/docs/service/base.md new file mode 100644 index 0000000..9881389 --- /dev/null +++ b/docs/service/base.md @@ -0,0 +1,13 @@ +# Base + +The Base class is used by both the Autodiscover and Operation classes. Each of these classes inherit from this class which defines static values for namespaces and other elements using the ElementMaker factory pattern. + +This class defines the SOAP Envelope, namespaces, headers, and body definitions for Autodiscover and Operation classes. + +Additionally, the Base class performs all HTTP requests and response validation. + +```eval_rst +.. automodule:: pyews.service.base.Base + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/service/operation.md b/docs/service/operation.md new file mode 100644 index 0000000..4eee462 --- /dev/null +++ b/docs/service/operation.md @@ -0,0 +1,11 @@ +# Operation + +The Operation class is used by all EWS Operations except ones inherited by from Autodiscover. + +This class defines the SOAP Envelope, namespaces, headers, and body definitions for any class that inherits from it. + +```eval_rst +.. automodule:: pyews.service.operation.Operation + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/service/root.md b/docs/service/root.md new file mode 100644 index 0000000..ba4b19a --- /dev/null +++ b/docs/service/root.md @@ -0,0 +1,16 @@ +# Service Classes + +The Service sub-package defines the structure and ultimately makes it easier to build SOAP requests for all classes inherited from these modules. + +All endpoints inherit from either the Autodiscover or Operation classes. These classes make extensibility much easier and allows users of this package to define new endpoints easily. + + +```eval_rst +.. toctree:: + :maxdepth: 2 + + autodiscover + base + operation + add_additional_service_endpoints +``` \ No newline at end of file diff --git a/docs/services/add_additional_service_endpoints.md b/docs/services/add_additional_service_endpoints.md deleted file mode 100644 index 1f91b29..0000000 --- a/docs/services/add_additional_service_endpoints.md +++ /dev/null @@ -1,58 +0,0 @@ -# Adding Additional EWS Service Endpoints - -As I stated above I will continue to add additional EWS Service Endpoints, but I wanted to share documentation on how to add your own support for additional service endpoints. - -All services are child classes of :doc:`serviceendpoint` to add a new EWS endpoint, you will first create a class that is a child class of ServiceEndpoint. - -I have provided a template class to expand or add new additional service endpoints: - -```python -from .serviceendpoint import ServiceEndpoint - -class NewEndpointName(ServiceEndpoint): - - def __init__(self, userconfiguration): - - # Call the parent class to verify that the userconfiguration object is a correct object and has the necessary properties - super(NewEndpointName, self).__init__(userconfiguration) - - # if you ned additional parameters for your service endpoint, then add them to the __init__ method and add logic here as needed - - # Create your SOAP XML message body - self._soap_request = self.soap() - - # Call the ServiceEndpoint invoke() method with your new SOAP XML body - super(DeleteItem, self).invoke(self._soap_request) - - # Transform the raw SOAP XML response body to a formatted dictionary or list in this classes response property - self.response = self.raw_soap - - def __parse_response(self, value): - # add logic here to parse the response object - pass - - def run(self): - self.raw_xml = self.invoke(self.soap()) - return self.__parse_response(self.raw_xml) - - def soap(self): - if (self.userconfiguration.impersonation): - impersonation_header = self.userconfiguration.impersonation.header - else: - impersonation_header = '' - - # create a SOAP XML formatted string here to return - - return ''' - - - - %s - - - # Add your body elements here - - ''' % (self.userconfiguration.exchangeVersion, impersonation_header) -``` \ No newline at end of file diff --git a/docs/services/findhiddeninboxrules.md b/docs/services/findhiddeninboxrules.md deleted file mode 100644 index d62c8a4..0000000 --- a/docs/services/findhiddeninboxrules.md +++ /dev/null @@ -1,11 +0,0 @@ -# FindHiddenInboxRules - -This documentation provides details about the FindHiddenInboxRules class within the `pyews` package. - -This class is used to get hidden mailbox rules from the specified user mailbox. - -```eval_rst -.. automodule:: pyews.FindHiddenInboxRules - :members: - :undoc-members: -``` \ No newline at end of file diff --git a/docs/services/root.md b/docs/services/root.md deleted file mode 100644 index 2d25318..0000000 --- a/docs/services/root.md +++ /dev/null @@ -1,18 +0,0 @@ -# Available Service Endpoints - -The following are the currently available service endpoints supported by pyews. We will continue to add additional endpoints for Exchange Web Services - -If you need a specific endpoint, please create an issue on our GitHub repository. - -```eval_rst -.. toctree:: - :maxdepth: 2 - - deleteitem - findhiddeninboxrules - getinboxrules - getsearchablemailboxes - resolvenames - searchmailboxes - add_additional_service_endpoints -``` \ No newline at end of file diff --git a/docs/utils/exceptions.md b/docs/utils/exceptions.md index e3f3bae..3edeaa7 100644 --- a/docs/utils/exceptions.md +++ b/docs/utils/exceptions.md @@ -3,42 +3,7 @@ This documentation provides details about Exceptions defined within the `pyews` package. ```eval_rst -.. automodule:: pyews.CredentialsError +.. automodule:: pyews.utils.exceptions.UknownValueError :members: :undoc-members: ``` - -```eval_rst -.. automodule:: pyews.IncorrectParameters - :members: - :undoc-members: -``` - -```eval_rst -.. automodule:: pyews.ExchangeVersionError - :members: - :undoc-members: -``` - -```eval_rst -.. automodule:: pyews.ObjectType - :members: - :undoc-members: -``` - - -```eval_rst -.. automodule:: pyews.SearchScopeError - :members: - :undoc-members: -``` -```eval_rst -.. automodule:: pyews.SoapAccessDeniedError - :members: - :undoc-members: -``` -```eval_rst -.. automodule:: pyews.SoapResponseHasError - :members: - :undoc-members: -``` \ No newline at end of file diff --git a/docs/utils/exchangeversion.md b/docs/utils/exchangeversion.md index 29bd92b..534d0e0 100644 --- a/docs/utils/exchangeversion.md +++ b/docs/utils/exchangeversion.md @@ -3,7 +3,7 @@ This documentation provides details about the ExchangeVersion class within the `pyews` package. ```eval_rst -.. automodule:: pyews.ExchangeVersion +.. automodule:: pyews.exchangeversion.ExchangeVersion :members: :undoc-members: ``` \ No newline at end of file diff --git a/docs/utils/logger.md b/docs/utils/logger.md index ef59267..1def226 100644 --- a/docs/utils/logger.md +++ b/docs/utils/logger.md @@ -3,7 +3,7 @@ This documentation provides details about the Logger class within the `pyews` package. ```eval_rst -.. automodule:: pyews.logger +.. automodule:: pyews.utils.logger.LoggingBase :members: :undoc-members: ``` \ No newline at end of file From 374bbb10d3a3948823d40c05943c9bcf3a1f2000 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:41:00 -0500 Subject: [PATCH 06/72] adding package data and entrypoint --- setup.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/setup.py b/setup.py index 51b6f0a..32bdce5 100644 --- a/setup.py +++ b/setup.py @@ -18,4 +18,12 @@ def parse_requirements(requirement_file): author='Swimlane', author_email='info@swimlane.com', python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*, <4', + package_data={ + 'pyews': ['data/logging.yml'] + }, + entry_points={ + 'console_scripts': [ + 'pyews = pyews.__main__:main' + ] + }, ) \ No newline at end of file From 01edbe4cf2e8d0714d4f15f6d3ff4bd3d764c831 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:41:38 -0500 Subject: [PATCH 07/72] Updated conf for docs --- docs/Makefile | 2 +- docs/conf.py | 2 +- docs/index.md | 158 +++++++++++++++++++++----------------------------- 3 files changed, 68 insertions(+), 94 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 298ea9e..e19c041 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,7 +3,7 @@ # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = sphinx-build +SPHINXBUILD = python3 -m sphinx SOURCEDIR = . BUILDDIR = _build diff --git a/docs/conf.py b/docs/conf.py index 783e147..c9949d4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -103,7 +103,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +#html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. diff --git a/docs/index.md b/docs/index.md index f7d8b43..038a85f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,15 +33,27 @@ * Delete email items from mailboxes in your Exchange environment * Retrieve mailbox inbox rules for a specific account * Find additional hidden inbox rules for a specified account +* Plus more supported endpoints Currently this package supports the following endpoint's: -* [DeleteItem](services/deleteitem.md) -* [GetInboxRules](services/getinboxrules.md) -* [FindHiddenInboxRules](services/findhiddeninboxrules.md) -* [GetSearchableMailboxes](services/getsearchablemailboxes.md) -* [ResolveNames](services/resolvenames.md) -* [SearchMailboxes](/services/searchmailboxes.md) +* [AddDelegate](endpoint/adddelegate.md) +* [ConvertId](endpoint/convertid.md) +* [CreateItem](endpoint/createitem.md) +* [DeleteItem](endpoint/deleteitem.md) +* [ExecuteSearch](endpoint/executesearch.md) +* [ExpandDL](endpoint/expanddl.md) +* [GetAttachment](endpoint/getattachment.md) +* [GetHiddenInboxRules](endpoint/gethiddeninboxrules.md) +* [GetInboxRules](endpoint/getinboxrules.md) +* [GetItem](endpoint/getitem.md) +* [GetSearchableMailboxes](endpoint/getsearchablemailboxes.md) +* [GetServiceConfiguration](endpoint/getserviceconfiguration.md) +* [GetUserSettings](endpoint/getusersettings.md) +* [ResolveNames](endpoint/resolvenames.md) +* [SearchMailboxes](endpoint/searchmailboxes.md) +* [SyncFolderHierarchy](endpoint/syncfolderhierarchy.md) +* [SyncFolderItems](endpoint/syncfolderitems.md) ## Installation @@ -58,122 +70,82 @@ pip install py-ews pip install py-ews ``` -## Usage example +## Creating EWS Object -The first step in using **py-ews** is that you need to create a [UserConfiguration](configuration/userconfiguration.md) object. Think of this as all the connection information for Exchange Web Services. An example of creating a [UserConfiguration](configuration/userconfiguration.md) using Office 365 Autodiscover is: +When instantiating the `EWS` class you will need to provide credentials which will be used for all methods within the EWS class. ```python -from pyews import UserConfiguration +from pyews import EWS -userconfig = UserConfiguration( +ews = EWS( 'myaccount@company.com', 'Password1234' ) ``` - -If you would like to use an alternative [Autodiscover](configuration/autodiscover.md) endpoint (or any alternative endpoint) then please provide one using the `endpoint` named parameter: +If you would like to use an alternative EWS URL then provide one using the `ews_url` parameter when instantiating the EWS class. ```python -from pyews import UserConfiguration +from pyews import EWS -userconfig = UserConfiguration( +ews = EWS( 'myaccount@company.com', 'Password1234', - endpoint='https://outlook.office365.com/autodiscover/autodiscover.svc' + ews_url='https://outlook.office365.com/autodiscover/autodiscover.svc' ) ``` -For more information about creating a [UserConfiguration](configuration/userconfiguration.md) object, please see the full documentation. +If you would like to specify a specific version of Exchange to use, you can provide that using the `exchange_version` parameter. By default `pyews` will attempt all Exchange versions as well as multiple static and generated EWS URLs. -Now that you have a [UserConfiguration](configuration/userconfiguration.md) object, we can now use any of the available service endpoints. This example will demonstrate how you can identify which mailboxes you have access to by using the [GetSearchableMailboxes](services/getsearchablemailboxes.md) EWS endpoint. +## Using Provided Methods -Once you have identified a list of mailbox reference ids, then you can begin searching all of those mailboxes by using the [SearchMailboxes](services/searchmailboxes.md) EWS endpoint. +Once you have instantiated the EWS class with your credentials, you will have access to pre-exposed methods for each endpoint. These methods are: -The returned results will then be deleted (moved to Deleted Items folder) from Exchange using the [DeleteItem](services/deleteitem.md) EWS endpoint. +* get_service_configuration +* get_searchable_mailboxes +* get_user_settings +* resolve_names +* execute_ews_search +* execute_outlook_search +* get_inbox_rules +* get_hidden_inbox_rules +* get_item +* sync_folder_hierarchy +* sync_folder_items +* create_item -```python -from pyews import UserConfiguration -from pyews import GetSearchableMailboxes -from pyews import SearchMailboxes -from pyews import DeleteItem +## Importing Endpoints -userconfig = UserConfiguration( - 'myaccount@company.com', - 'Password1234' -) - -# get searchable mailboxes based on your accounts permissions -referenceid_list = [] -for mailbox in GetSearchableMailboxes(userconfig).run(): - referenceid_list.append(mailbox['reference_id']) - -# let's search all the referenceid_list items -messages_found = [] -for search in SearchMailboxes(userconfig).run('subject:account', referenceid_list): - messages_found.append(search['id']) - # we can print the results first if we want - print(search['subject']) - print(search['id']) - print(search['sender']) - print(search['to_recipients']) - print(search['created_time']) - print(search['received_time']) - #etc. - -# if we wanted to now delete a specific message then we would call the DeleteItem -# class like this but we can also pass in the entire messages_found list -deleted_message_response = DeleteItem(userconfig).run(messages_found[2]) - -print(deleted_message_response) -``` - -The following is an example of the output returned when calling the above code: - -```text -YOUR ACCOUNT IS ABOUT TO EXPIRE! UPGRADE NOW!!! -AAMkAGZjOTlkOWExLTM2MDEtNGI3MS0.............. -Josh Rickard -Research -2019-02-28T18:28:36Z -2019-02-28T18:28:36Z -Upgrade Your Account! -AAMkADAyNTZhNmMyLWNmZTctNDIyZC0.............. -Josh Rickard -Josh Rickard -2019-01-24T18:41:11Z -2019-01-24T18:41:11Z -New or modified user account information -AAMkAGZjOTlkOWExLTM2MDEtNGI3MS04.............. -Microsoft Online Services Team -Research -2019-01-24T18:38:06Z -2019-01-24T18:38:06Z -[{'MessageText': 'Succesfull'}] -``` +If you would like to write your own methods, you can import each endpoint directly into your script. +This example will demonstrate how you can identify which mailboxes you have access to by using the [GetSearchableMailboxes](endpoint/getsearchablemailboxes.md) EWS endpoint. -**For more examples and usage, please refer to the individual class documentation** - -* [Services](services/root.md) -* [Configuration](configuration/root.md) - -## Development setup +```python +from pyews import Core +from pyews.endpoint import GetSearchableMailboxes -I have provided a [Dockerfile](https://github.com/swimlane/pyews/blob/master/Dockerfile) with all the dependencies and it is currently calling [test.py](https://github.com/swimlane/pyews/blob/master/Dockerfilebin\pyews_test.py). If you want to test new features, I recommend that you use this [Dockerfile](https://github.com/swimlane/pyews/blob/master/Dockerfile). You can call the following to build a new container, but keep the dependencies unless they have changed in your requirements.txt or any other changes to the [Dockerfile](https://github.com/swimlane/pyews/blob/master/Dockerfile). +Core.exchange_versions = 'Exchange2016' +Core.credentials = ('mymailbox@mailbox.com', 'some password') +Core.endpoints = 'mailbox.com' +reference_id_list = [] +for mailbox in GetSearchableMailboxes().run(): + reference_id_list.append(mailbox.get('reference_id')) + print(mailbox) ``` -docker build --force-rm -t pyews . -``` -To run the container, use the following: +Once you have identified a list of mailbox reference ids, then you can begin searching all of those mailboxes by using the [SearchMailboxes](endpoint/searchmailboxes.md) EWS endpoint. + +```python +from pyews.endpoint import SearchMailboxes -``` -docker run pyews +for search_item in SearchMailboxes('phish', reference_id_list).run(): + print(search_item) ``` -I am new to Unit Testing, but I am working on that as time permits. If you would like to help, I wouldn't be sad about it. :) +**For more examples and usage, please refer to the individual class documentation** +* [Endpoint](service/root.md) ## Release History @@ -181,6 +153,8 @@ I am new to Unit Testing, but I am working on that as time permits. If you woul * Initial release of py-ews and it is still considered a work in progress * 2.0.0 * Revamped logic and overhauled all endpoints and classes +* 3.0.0 + * Refactored completely - this can be considered a new version ## Meta @@ -202,7 +176,7 @@ Distributed under the MIT license. See ``LICENSE`` for more information. :maxdepth: 2 :caption: Contents: - services/root - configuration/root + endpoint/root + service/root utils/root ``` From b8fabae66397034f971b89035fc99afb7ae3ad93 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:41:53 -0500 Subject: [PATCH 08/72] removed and added imports to main package --- pyews/__init__.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/pyews/__init__.py b/pyews/__init__.py index c2119b4..add7042 100644 --- a/pyews/__init__.py +++ b/pyews/__init__.py @@ -1,16 +1 @@ -from pyews.utils.logger import setup_logging -setup_logging() - -from .core import Core -from .configuration.userconfiguration import UserConfiguration -from .configuration.endpoint import Endpoint -from .configuration.autodiscover import Autodiscover -from .configuration.credentials import Credentials -from .service.resolvenames import ResolveNames -from .service.deleteitem import DeleteItem -from .service.getinboxrules import GetInboxRules -from .service.findhiddeninboxrules import FindHiddenInboxRules -from .service.getsearchablemailboxes import GetSearchableMailboxes -from .service.searchmailboxes import SearchMailboxes -from .utils.exchangeversion import ExchangeVersion -from .utils.exceptions import CredentialsError, IncorrectParameters, SoapConnectionError, SoapConnectionRefused, ExchangeVersionError, ObjectType, SearchScopeError, SoapAccessDeniedError, SoapResponseHasError, SoapResponseIsNoneError, DeleteTypeError, UserConfigurationError \ No newline at end of file +from .ews import EWS \ No newline at end of file From 7d803974212496f87295faeeb158daa84c2b99e3 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:42:12 -0500 Subject: [PATCH 09/72] Added __main__ entrypoint for console access --- pyews/__main__.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 pyews/__main__.py diff --git a/pyews/__main__.py b/pyews/__main__.py new file mode 100644 index 0000000..7f57194 --- /dev/null +++ b/pyews/__main__.py @@ -0,0 +1,8 @@ +import fire +from pyews import EWS + +def main(): + fire.Fire(EWS()) + +if __name__ == "__main__": + main() From 02cacabc8003a9b8b1bcc801e116df200d78fe87 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:42:25 -0500 Subject: [PATCH 10/72] A new completely revamped core class --- pyews/core.py | 177 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 113 insertions(+), 64 deletions(-) diff --git a/pyews/core.py b/pyews/core.py index d7c6b53..42da45c 100644 --- a/pyews/core.py +++ b/pyews/core.py @@ -1,23 +1,66 @@ -import logging -import requests import xmltodict import json -from bs4 import BeautifulSoup -__LOGGER__ = logging.getLogger(__name__) +from .utils.logger import LoggingBase -class Core: +class Core(metaclass=LoggingBase): + """The Core class inherits logging and defines + required authentication details as well as parsing of results + """ - SOAP_REQUEST_HEADER = {'content-type': 'text/xml; charset=UTF-8'} + @property + def credentials(cls): + return cls._credentials - def __init__(self, userconfiguration): - '''Parent class of all endpoints implemented within pyews + @credentials.setter + def credentials(cls, value): + if isinstance(value, tuple): + cls.domain = value[0] + cls._credentials = value + raise AttributeError('Please provide both a username and password') + + @property + def exchange_versions(cls): + return cls._exchange_versions + + @exchange_versions.setter + def exchange_versions(cls, value): + from .exchangeversion import ExchangeVersion + if not value: + cls._exchange_versions = ExchangeVersion.EXCHANGE_VERSIONS + elif not isinstance(value, list): + cls._exchange_versions = [value] + else: + cls._exchange_versions = value + + @property + def endpoints(cls): + return cls._endpoints + + @endpoints.setter + def endpoints(cls, value): + from .endpoints import Endpoints + if not value: + cls._endpoints = Endpoints(cls.domain).get() + elif not isinstance(value, list): + cls._endpoints = [value] + else: + cls._endpoints = value + + @property + def domain(cls): + return cls._domain + + @domain.setter + def domain(cls, value): + '''Splits the domain from an email address - Args: - userconfiguration (UserConfiguration): A UserConfiguration object created using the UserConfiguration class + Returns: + str: Returns the split domain from an email address ''' - self.userconfiguration = userconfiguration + local, _, domain = value.partition('@') + cls._domain = domain def camel_to_snake(self, s): if s != 'UserDN': @@ -25,58 +68,64 @@ def camel_to_snake(self, s): else: return 'user_dn' - def invoke(self, soap_body): - '''Used to invoke an Autodiscover SOAP request - - Args: - soap_request (str): A formatted SOAP XML request body string - userconfiguration (UserConfiguration): A UserConfiguration object created using the UserConfiguration class + def __process_keys(self, key): + return_value = key.replace('t:','') + if return_value.startswith('@'): + return_value = return_value.lstrip('@') + return self.camel_to_snake(return_value) - Raises: - SoapResponseHasError: Raises an error when unable to parse a SOAP response - ''' - endpoint = self.userconfiguration.ews_url - try: - response = requests.post( - url=endpoint, - data=soap_body, - headers=self.SOAP_REQUEST_HEADER, - auth=(self.userconfiguration.credentials.email_address, self.userconfiguration.credentials.password), - verify=True - ) - - __LOGGER__.debug('Response HTTP status code: %s', response.status_code) - __LOGGER__.debug('Response text: %s', response.text) - - parsed_response = BeautifulSoup(response.content, 'xml') - if not parsed_response.contents: - __LOGGER__.warning( - 'The server responded with empty content to POST-request ' - 'from {current}'.format(current=self.__class__.__name__)) - return - - response_code = getattr(parsed_response.find('ResponseCode'), 'string', None) - error_code = getattr(parsed_response.find('ErrorCode'), 'string', None) - - if 'NoError' in (response_code, error_code): - return parsed_response - elif 'ErrorAccessDenied' in (response_code, error_code): - __LOGGER__.warning( - 'The server responded with "ErrorAccessDenied" ' - 'response code to POST-request from {current}'.format( - current=self.__class__.__name__)) - else: - __LOGGER__.warning( - 'The server responded with unknown "ResponseCode" ' - 'and "ErrorCode" from {current}'.format( - current=self.__class__.__name__)) - except requests.exceptions.HTTPError as errh: - __LOGGER__.info("An Http Error occurred attempting to connect to {ep}:".format(ep=endpoint) + repr(errh)) - except requests.exceptions.ConnectionError as errc: - __LOGGER__.info("An Error Connecting to the API occurred attempting to connect to {ep}:".format(ep=endpoint) + repr(errc)) - except requests.exceptions.Timeout as errt: - __LOGGER__.info("A Timeout Error occurred attempting to connect to {ep}:".format(ep=endpoint) + repr(errt)) - except requests.exceptions.RequestException as err: - __LOGGER__.info("An Unknown Error occurred attempting to connect to {ep}:".format(ep=endpoint) + repr(err)) - return None + def _process_dict(self, obj): + if isinstance(obj, dict): + obj = { + self.__process_keys(key): self._process_dict(value) for key, value in obj.items() + } + return obj + + def _get_recursively(self, search_dict, field): + """ + Takes a dict with nested lists and dicts, + and searches all dicts for a key of the field + provided. + """ + fields_found = [] + if search_dict: + for key, value in search_dict.items(): + if key == field: + fields_found.append(value) + elif isinstance(value, dict): + results = self._get_recursively(value, field) + for result in results: + fields_found.append(result) + elif isinstance(value, list): + for item in value: + if isinstance(item, dict): + more_results = self._get_recursively(item, field) + for another_result in more_results: + fields_found.append(another_result) + return fields_found + + def parse_response(self, soap_response, namespace_dict=None): + """parse_response is standardized to parse all soap_responses from + EWS requests + + Args: + soap_response (BeautifulSoup): EWS SOAP response returned from the Base class + namespace_dict (dict, optional): A dictionary of namespaces to process. Defaults to None. + Returns: + list: Returns a list of dictionaries containing parsed responses from EWS requests. + """ + ordered_dict = xmltodict.parse(str(soap_response), process_namespaces=True, namespaces=namespace_dict) + item_dict = json.loads(json.dumps(ordered_dict)) + if hasattr(self, 'RESULTS_KEY'): + search_response = self._get_recursively(item_dict, self.RESULTS_KEY) + if search_response: + return_list = [] + for item in search_response: + if isinstance(item,list): + for i in item: + return_list.append(self._process_dict(i)) + else: + return_list.append(self._process_dict(item)) + return return_list + return self._process_dict(item_dict) From a6f2420c8f057658ebe65ba0e1d2474d5101cfcd Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:42:47 -0500 Subject: [PATCH 11/72] moved endpoints to root --- pyews/endpoints.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 pyews/endpoints.py diff --git a/pyews/endpoints.py b/pyews/endpoints.py new file mode 100644 index 0000000..70ebe27 --- /dev/null +++ b/pyews/endpoints.py @@ -0,0 +1,14 @@ +class Endpoints: + """Endpoints provides and generates endpoints to attempt + EWS requests against. + """ + + def __init__(self, domain=None): + self.domain = domain + + def get(self): + endpoint_list = ['https://outlook.office365.com/autodiscover/autodiscover.svc', 'https://outlook.office365.com/EWS/Exchange.asmx', 'https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc'] + if self.domain: + endpoint_list.append("https://{}/autodiscover/autodiscover.svc".format(self.domain)) + endpoint_list.append("https://autodiscover.{}/autodiscover/autodiscover.svc".format(self.domain)) + return endpoint_list From f57a1ae211bd57d83aec89251d3be4c0f54207bf Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:43:00 -0500 Subject: [PATCH 12/72] ews py is the main entry point going forward --- pyews/ews.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 pyews/ews.py diff --git a/pyews/ews.py b/pyews/ews.py new file mode 100644 index 0000000..3f2db04 --- /dev/null +++ b/pyews/ews.py @@ -0,0 +1,93 @@ +from .core import Core +from .endpoint import GetSearchableMailboxes, GetUserSettings, ResolveNames, SearchMailboxes, ExecuteSearch, GetInboxRules, GetItem, ConvertId, GetHiddenInboxRules, CreateItem, GetServiceConfiguration, SyncFolderHierarchy, SyncFolderItems, GetAttachment +from .exchangeversion import ExchangeVersion +from .endpoints import Endpoints + + +class EWS: + + _credentials = None + _ews_url = None + _exchange_version = None + + def __init__(self, username, password, ews_url=None, exchange_version=ExchangeVersion.EXCHANGE_VERSIONS): + Core.exchange_versions = exchange_version + Core.credentials = (username, password) + Core.domain = username + if not ews_url: + local, _, domain = username.partition('@') + Core.endpoints = Endpoints(domain).get() + elif not isinstance(ews_url, list): + Core.endpoints = [ews_url] + else: + Core.endpoints = ews_url + + def get_service_configuration(self, configuration_name=None, acting_as=None): + return GetServiceConfiguration(configuration_name=configuration_name, acting_as=acting_as).run() + + def get_searchable_mailboxes(self, search_filter=None, expand_group_memberhip=True): + return GetSearchableMailboxes(search_filter=search_filter, expand_group_memberhip=expand_group_memberhip).run() + + def get_user_settings(self, user=None): + return GetUserSettings(user=user).run() + + def resolve_names(self, user=None): + return ResolveNames(user=user).run() + + def execute_ews_search(self, query, reference_id, search_scope='All'): + response = SearchMailboxes(query, reference_id=reference_id, search_scope=search_scope).run() + return_list = [] + for item in response: + return_dict = item + get_item_response = self.get_item(return_dict['id'].get('id')) + if get_item_response: + for response in get_item_response: + if response.get('message').get('attachments').get('file_attachment').get('attachment_id').get('id'): + attachment_details_list = [] + attachment = self.get_attachment(response.get('message').get('attachments').get('file_attachment').get('attachment_id').get('id')) + for attach in attachment: + attachment_dict = {} + for key,val in attach.items(): + for k,v in val.items(): + attachment_dict[k] = v + if attachment_dict: + attachment_details_list.append(attachment_dict) + return_dict.update(response.pop('message')) + if attachment_details_list: + return_dict.update({'attachment_details': attachment_details_list}) + return_list.append(return_dict) + return return_list + + def execute_outlook_search(self, query, result_row_count='25', max_results_count='-1'): + return ExecuteSearch( + query=query, + result_row_count=result_row_count, + max_results_count=max_results_count + ).run() + + def get_inbox_rules(self, user=None): + return GetInboxRules(user=user).run() + + def get_hidden_inbox_rules(self): + return GetHiddenInboxRules().run() + + def get_item(self, item_id, change_key=None): + response = GetItem(item_id, change_key=change_key).run() + if isinstance(response, list): + if any(item in response for item in ConvertId.ID_FORMATS): + convert_id_response = ConvertId(Core.credentials[0], item_id, id_type=response[0], convert_to=response[1]).run() + get_item_response = GetItem(convert_id_response[0]).run() + return get_item_response if get_item_response else None + return GetItem(item_id, change_key=change_key).run() + + def get_attachment(self, attachment_id): + return GetAttachment(attachment_id=attachment_id).run() + + def sync_folder_hierarchy(self, well_known_folder_name=None): + return SyncFolderHierarchy(well_known_folder_name=well_known_folder_name).run() + + def sync_folder_items(self, folder_id, change_key=None): + return SyncFolderItems(folder_id, change_key=change_key).run() + + def create_item(self, subject, sender, to_recipients, body_type='HTML'): + return CreateItem(**{'Subject': subject, 'BodyType': body_type, 'Sender': sender, 'ToRecipients': to_recipients}).run() From 237a665cb07708ab9ef093a70d200ba659125f6a Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:43:11 -0500 Subject: [PATCH 13/72] exchange version moved to root --- pyews/exchangeversion.py | 92 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 pyews/exchangeversion.py diff --git a/pyews/exchangeversion.py b/pyews/exchangeversion.py new file mode 100644 index 0000000..8f0df70 --- /dev/null +++ b/pyews/exchangeversion.py @@ -0,0 +1,92 @@ +class ExchangeVersion: + '''Used to validate compatible Exchange Versions across multiple service endpoints + + Examples: + To determine if a version number is a valid ExchangeVersion then would pass in the value when instantiating this object: + + ```python + version = ExchangeVersion('15.20.5').exchangeVersion + print(version) + ``` + + ```text + # output + Exchange2016 + ``` + + To verify an ExchangeVersion is supported, you can view the supported version by access the EXCHANGE_VERSIONS attribute + + ```python + versions = ExchangeVersion('15.20.5').EXCHANGE_VERSIONS + print(versions) + ``` + + ```text + ['Exchange2019', 'Exchange2016', 'Exchange2013_SP1', 'Exchange2013', 'Exchange2010_SP2', 'Exchange2010_SP1', 'Exchange2010'] + ``` + + Args: + version (str): An Exchange Version number. Example: 15.20.5 = Exchange2016 + ''' + + # Borrowed from exchangelib: https://github.com/ecederstrand/exchangelib/blob/master/exchangelib/version.py#L54 + # List of build numbers here: https://docs.microsoft.com/en-us/Exchange/new-features/build-numbers-and-release-dates?view=exchserver-2019 + API_VERSION_MAP = { + 8: { + 0: 'Exchange2007', + 1: 'Exchange2007_SP1', + 2: 'Exchange2007_SP1', + 3: 'Exchange2007_SP1', + }, + 14: { + 0: 'Exchange2010', + 1: 'Exchange2010_SP1', + 2: 'Exchange2010_SP2', + 3: 'Exchange2010_SP2', + }, + 15: { + 0: 'Exchange2013', # Minor builds starting from 847 are Exchange2013_SP1, see api_version() + 1: 'Exchange2016', + 2: 'Exchange2019', + 20: 'Exchange2016', # This is Office365. See issue #221 + } + } + + EXCHANGE_VERSIONS = ['Exchange2019', 'Exchange2016', 'Exchange2015', 'Exchange2013_SP1', 'Exchange2013', 'Exchange2010_SP2', 'Exchange2010_SP1', 'Exchange2010'] + + def __init__(self, version): + self.exchangeVersion = self._get_api_version(version) + + def _get_api_version(self, version): + '''Gets a string representation of an Exchange Version number + + Args: + version (str): An Exchange Version number. Example: 15.20.5 + + Returns: + str: A string representation of a Exchange Version number. Example: Exchange2016 + ''' + + if version == '15.0.847.32': + return 'Exchange2013_SP1' + elif version == '15.20.3955.27': + return 'Exchange2015' + else: + ver = version.split('.') + return self.API_VERSION_MAP[int(ver[0])][int(ver[1])] + + @staticmethod + def valid_version(version): + '''Determines if a string version name is in list of accepted Exchange Versions + + Args: + version (str): String used to determine if it is an acceptable Exchange Version + + Returns: + bool: Returns either True or False if the passed in version is an acceptable Exchange Version + ''' + if version == 'Office365': + return True + elif version in ExchangeVersion.EXCHANGE_VERSIONS: + return True + return False From 541d784cbaee514d81bf21b7f0c82a87fabf944c Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:43:59 -0500 Subject: [PATCH 14/72] Removed entire core code base --- pyews/configuration/__init__.py | 0 pyews/configuration/autodiscover.py | 63 ---------- pyews/configuration/credentials.py | 48 -------- pyews/configuration/endpoint.py | 13 -- pyews/configuration/impersonation.py | 57 --------- pyews/configuration/userconfiguration.py | 146 ----------------------- 6 files changed, 327 deletions(-) delete mode 100644 pyews/configuration/__init__.py delete mode 100644 pyews/configuration/autodiscover.py delete mode 100644 pyews/configuration/credentials.py delete mode 100644 pyews/configuration/endpoint.py delete mode 100644 pyews/configuration/impersonation.py delete mode 100644 pyews/configuration/userconfiguration.py diff --git a/pyews/configuration/__init__.py b/pyews/configuration/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pyews/configuration/autodiscover.py b/pyews/configuration/autodiscover.py deleted file mode 100644 index a1e7910..0000000 --- a/pyews/configuration/autodiscover.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -import requests -from ..core import Core -from .endpoint import Endpoint -from ..service.getusersettings import GetUserSettings -from ..utils.exchangeversion import ExchangeVersion - -__LOGGER__ = logging.getLogger(__name__) - - -class Autodiscover(Core): - '''A class used to connect to Exchange Web Services using the Autodiscover service endpoint - - The Autodiscover class can be used with both Office 365 and on-premises Exchange 2010 to 2019. - Currently, it has been thoroughly tested with Office 365 but not so much with the on-premises versions of Exchange. - - Example: - - There two typical methods of using the Autodiscover class. These behave slightly differently depending on your needs. - - 1. If you know the Autodiscover URL for your on-premises or Office 365 Exchange Autodiscover service then you can provide this directly - - ```python - Autodiscover( - credentialObj, - endpoint='https://outlook.office365.com/autodiscover/autodiscover.svc', - exchangeVersion='Office365' - ) - ``` - - 2. If you do not know the Autodiscover URL then you can set create_endpoint_list to True to have the Autodiscover class attempt to generate URL endpoints for you - - ```python - Autodiscover( - credentialObj, - create_endpoint_list=True - ) - ``` - - Args: - credentials (Credentials): An object created using the Credentials class - endpoint (str, optional): Defaults to None. If you want to specify a different Autodiscover endpoint then provide the url here - exchangeVersion (str, optional): Defaults to None. An exchange version string - create_endpoint_list (bool, optional): Defaults to False. If you want the Autodiscover class to generate a list of endpoints to try based on a users email address - ''' - def __init__(self, userconfiguration): - super(Autodiscover, self).__init__(userconfiguration) - if not getattr(self.userconfiguration, 'ews_url'): - self.ews_url = Endpoint(domain=self.userconfiguration.credentials.domain).get() - self.exchange_version = self.userconfiguration.exchange_version - - def run(self): - for version in self.exchange_version: - for endpoint in self.ews_url: - try: - requests.get(endpoint) - self.userconfiguration.ews_url = endpoint - self.userconfiguration.exchange_version = version - autodiscover = GetUserSettings(self.userconfiguration).run(endpoint, version) - except: - continue - if autodiscover: - return autodiscover diff --git a/pyews/configuration/credentials.py b/pyews/configuration/credentials.py deleted file mode 100644 index 32b7e3b..0000000 --- a/pyews/configuration/credentials.py +++ /dev/null @@ -1,48 +0,0 @@ -import re - - -class Credentials: - '''Creates a credential object for communicating with EWS - - Example: - - Here is a basic example of createing a new Credentials object - - ```python - creds = Credentials( - 'first.last@company.com', - 'mypassword123' - ) - ``` - - You can access your credential properties, including a domain property like this: - - ```python - print(creds.email_address) - print(creds.password) - print(creds.domain) - ``` - - Args: - email_address (str): The email address used in the EWS SOAP request - password (str): The password used in the EWS SOAP request - ''' - - def __init__(self, email_address, password): - self.email_address = email_address - self.password = password - self.domain = self.email_address - - @property - def domain(self): - return self._domain - - @domain.setter - def domain(self, value): - '''Splits the domain from an email address - - Returns: - str: Returns the split domain from an email address - ''' - local, _, domain = value.partition('@') - self._domain = domain \ No newline at end of file diff --git a/pyews/configuration/endpoint.py b/pyews/configuration/endpoint.py deleted file mode 100644 index 446a3b7..0000000 --- a/pyews/configuration/endpoint.py +++ /dev/null @@ -1,13 +0,0 @@ - - -class Endpoint: - - def __init__(self, domain=None): - self.domain = domain - - def get(self): - endpoint_list = ['https://outlook.office365.com/autodiscover/autodiscover.svc'] - if self.domain: - endpoint_list.append("https://{}/autodiscover/autodiscover.svc".format(self.domain)) - endpoint_list.append("https://autodiscover.{}/autodiscover/autodiscover.svc".format(self.domain)) - return endpoint_list diff --git a/pyews/configuration/impersonation.py b/pyews/configuration/impersonation.py deleted file mode 100644 index 47311cc..0000000 --- a/pyews/configuration/impersonation.py +++ /dev/null @@ -1,57 +0,0 @@ - - -class Impersonation: - '''The Impersonation class is used when you want to impersonate a user. - - You must access rights to impersonate a specific user within your Exchange environment. - - Example: - - Below are examples of the data inputs expected for all parameters. - - ```python - Impersonation(principalname='first.last@company.com') - Impersonation(primarysmtpaddress='first.last@company.com') - Impersonation(smtpaddress='first.last@company.com') - ``` - - Args: - principalname (str, optional): The PrincipalName of the account you want to impersonate - sid (str, optional): The SID of the account you want to impersonate - primarysmtpaddress (str, optional): The PrimarySmtpAddress of the account you want to impersonate - smtpaddress (bool, optional): The SmtpAddress of the account you want to impersonate - - Raises: - AttributeError: This will raise when you call this class but do not provide at least 1 parameter - ''' - def __init__(self, principalname=None, sid=None, primarysmtpaddress=None, smtpaddress=None): - if principalname: - self.impersonation_type = 'PrincipalName' - self.impersonation_value = principalname - elif sid: - self.impersonation_type = 'SID' - self.impersonation_value = sid - elif primarysmtpaddress: - self.impersonation_type = 'PrimarySmtpAddress' - self.impersonation_value = primarysmtpaddress - elif smtpaddress: - self.impersonation_type = 'SmtpAddress' - self.impersonation_value = smtpaddress - else: - raise AttributeError('By setting impersonation to true you must provide either a PrincipalName, SID, PrimarySmtpAddress, or SmtpAddress') - - def get(self): - return self.__create_impersonation_header() - - def __create_impersonation_header(self): - return ''' - - - {value} - - -'''.format( - start_type=self.impersonation_type, - value=self.impersonation_value, - end_type=self.impersonation_type -) diff --git a/pyews/configuration/userconfiguration.py b/pyews/configuration/userconfiguration.py deleted file mode 100644 index 071a06e..0000000 --- a/pyews/configuration/userconfiguration.py +++ /dev/null @@ -1,146 +0,0 @@ -import logging -from ..core import Core -from .credentials import Credentials -from .autodiscover import Autodiscover -from .impersonation import Impersonation -from ..service.resolvenames import ResolveNames -from ..utils.exceptions import IncorrectParameters -from ..utils.exchangeversion import ExchangeVersion - -__LOGGER__ = logging.getLogger(__name__) - - -class UserConfiguration(Core): - '''UserConfiguration is the main class of pyews. - - It is used by all other ServiceEndpoint parent and child classes. - This class represents how you authorize communication with all other SOAP requests throughout this package. - - Examples: - - The UserConfiguration class is the main class used by all services, including the parent class of services called ServiceEndpoint. - - A UserConfiguration object contains detailed information about how to communicate to Exchange Web Services, as well as additional properties - - The traditional UserConfiguration object can be created by just passing in a username and password. This will attempt to connect using Autodiscover and will attempt every version of Exchange. - - ```python - userConfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123' - ) - ``` - - If you the know the Exchange version you want to communicate with you can provide this information: - - ```python - userConfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123', - exchangeVersion='Office365' - ) - ``` - - If you do not wish to use Autodiscover then you can tell UserConfiguration to not use it by setting autodiscover to False and provide the ewsUrl instead - - ```python - userConfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123', - autodiscover=False, - ewsUrl='https://outlook.office365.com/EWS/Exchange.asmx' - ) - ``` - - If you would like to use impersonation, you first need to create an Impersonation object and pass that into the UserConfiguration class when creating a user configuration object. - - ```python - impersonation = Impersonation(primarysmtpaddress='first.last@company.com') - - userConfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123', - autodiscover=False, - ewsUrl='https://outlook.office365.com/EWS/Exchange.asmx', - impersonation=impersonation - ) - ``` - Args: - username (str): An email address or username that you use to authenticate to Exchange Web Services - password (str): The password that you use to authenticate to Exchange Web Services - exchangeVersion (str, optional): Defaults to None. A string representation of the version of Exchange you are connecting to. - If no version is provided then we will attempt to use Autodiscover to determine this version from all approved versions - ewsUrl (str, optional): Defaults to None. An alternative Exchange Web Services URL. - autodiscover (bool, optional): Defaults to True. If you don't want to use Autodiscover then set this to False, but you must provide a ewsUrl. - impersonation (bool, optional): Defaults to False. If your credentials have impersonation rights then you can set this value to True to impersonate another user - but you must provide either a principalname, sid, primarysmtpaddress, or smtpaddress to do so. - principalname ([type], optional): Defaults to None. Only used when impersonation is set to True. The PrincipalName of the account you want to impersonate - sid ([type], optional): Defaults to None. Only used when impersonation is set to True. The SID of the account you want to impersonate - primarysmtpaddress ([type], optional): Defaults to None. Only used when impersonation is set to True. The PrimarySmtpAddress of the account you want to impersonate - smtpaddress ([type], optional): Defaults to None. Only used when impersonation is set to True. The SmtpAddress of the account you want to impersonate - - Raises: - IncorrectParameters: Provided an incorrect configuration of parameters to this class - ''' - - __config_properties = {} - - def __init__(self, username, password, exchange_version=None, ews_url= None, autodiscover=True, impersonation=None): - self.username = username - self.password = password - self.credentials = Credentials(self.username, self.password) - if not exchange_version: - self.exchange_version = ExchangeVersion.EXCHANGE_VERSIONS - else: - self.exchange_version = exchange_version if not isinstance(exchange_version, list) else [exchange_version] - self.ews_url = ews_url - self.impersonation = impersonation - self.autodiscover = autodiscover - - if self.autodiscover: - self.__config_properties = Autodiscover(self).run() - if self.__config_properties.get('external_ews_url'): - self.ews_url = self.__config_properties['external_ews_url'] - else: - if self.ews_url: - self.exchange_version = 'Exchange2010' - self.__config_properties = ResolveNames(self).run() - else: - raise IncorrectParameters('If you are not using Autodiscover then you must provide a ews_url and exchange_version.') - - @property - def impersonation(self): - return self._impersonation - - @impersonation.setter - def impersonation(self, value): - if isinstance(value, Impersonation): - self._impersonation = value - else: - self._impersonation = None - - def get(self): - self.__config_properties.update(self.__parse_properties()) - return self.__config_properties - - def __parse_properties(self): - return_dict = {} - temp = vars(self) - for item in temp: - if not item.startswith('_'): - return_dict.update({ - self.camel_to_snake(item): temp[item] - }) - if self.__config_properties: - for key,val in self.__config_properties.items(): - return_value = None - if ', ' in val: - return_value = [] - for v in val.split(','): - return_value.append(v) - else: - return_value = val - return_dict.update({ - self.camel_to_snake(key): val - }) - return return_dict From b90df3f92b9afa13a567a2c74d90bc0c4e7d530a Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Thu, 29 Apr 2021 21:44:55 -0500 Subject: [PATCH 15/72] Completey NEW package - revamped from the feet up --- pyews/data/logging.yml | 50 +++ pyews/endpoint/__init__.py | 15 + pyews/endpoint/adddelegate.py | 81 +++++ pyews/endpoint/convertid.py | 50 +++ pyews/endpoint/createitem.py | 75 ++++ pyews/endpoint/deleteitem.py | 34 ++ pyews/endpoint/executesearch.py | 38 ++ pyews/endpoint/expanddl.py | 23 ++ pyews/endpoint/getattachment.py | 27 ++ pyews/endpoint/gethiddeninboxrules.py | 37 ++ pyews/endpoint/getinboxrules.py | 23 ++ pyews/endpoint/getitem.py | 55 +++ pyews/endpoint/getsearchablemailboxes.py | 26 ++ pyews/endpoint/getserviceconfiguration.py | 59 ++++ pyews/endpoint/getusersettings.py | 42 +++ pyews/endpoint/resolvenames.py | 25 ++ pyews/endpoint/searchmailboxes.py | 53 +++ pyews/endpoint/syncfolderhierarchy.py | 57 +++ pyews/endpoint/syncfolderitems.py | 411 ++++++++++++++++++++++ pyews/service/__init__.py | 3 +- pyews/service/autodiscover.py | 47 +++ pyews/service/base.py | 160 +++++++++ pyews/service/deleteitem.py | 134 ------- pyews/service/findhiddeninboxrules.py | 101 ------ pyews/service/getinboxrules.py | 112 ------ pyews/service/getitem.py | 94 ----- pyews/service/getsearchablemailboxes.py | 91 ----- pyews/service/getusersettings.py | 93 ----- pyews/service/operation.py | 26 ++ pyews/service/resolvenames.py | 87 ----- pyews/service/searchmailboxes.py | 178 ---------- pyews/utils/attributes.py | 183 ++++++++++ pyews/utils/exceptions.py | 60 +--- pyews/utils/exchangeversion.py | 92 ----- pyews/utils/logger.py | 58 ++- requirements.txt | 11 +- 36 files changed, 1658 insertions(+), 1053 deletions(-) create mode 100644 pyews/data/logging.yml create mode 100644 pyews/endpoint/__init__.py create mode 100644 pyews/endpoint/adddelegate.py create mode 100644 pyews/endpoint/convertid.py create mode 100644 pyews/endpoint/createitem.py create mode 100644 pyews/endpoint/deleteitem.py create mode 100644 pyews/endpoint/executesearch.py create mode 100644 pyews/endpoint/expanddl.py create mode 100644 pyews/endpoint/getattachment.py create mode 100644 pyews/endpoint/gethiddeninboxrules.py create mode 100644 pyews/endpoint/getinboxrules.py create mode 100644 pyews/endpoint/getitem.py create mode 100644 pyews/endpoint/getsearchablemailboxes.py create mode 100644 pyews/endpoint/getserviceconfiguration.py create mode 100644 pyews/endpoint/getusersettings.py create mode 100644 pyews/endpoint/resolvenames.py create mode 100644 pyews/endpoint/searchmailboxes.py create mode 100644 pyews/endpoint/syncfolderhierarchy.py create mode 100644 pyews/endpoint/syncfolderitems.py create mode 100644 pyews/service/autodiscover.py create mode 100644 pyews/service/base.py delete mode 100644 pyews/service/deleteitem.py delete mode 100644 pyews/service/findhiddeninboxrules.py delete mode 100644 pyews/service/getinboxrules.py delete mode 100644 pyews/service/getitem.py delete mode 100644 pyews/service/getsearchablemailboxes.py delete mode 100644 pyews/service/getusersettings.py create mode 100644 pyews/service/operation.py delete mode 100644 pyews/service/resolvenames.py delete mode 100644 pyews/service/searchmailboxes.py create mode 100644 pyews/utils/attributes.py delete mode 100644 pyews/utils/exchangeversion.py diff --git a/pyews/data/logging.yml b/pyews/data/logging.yml new file mode 100644 index 0000000..5610cc7 --- /dev/null +++ b/pyews/data/logging.yml @@ -0,0 +1,50 @@ +--- +version: 1 +disable_existing_loggers: False +formatters: + simple: + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +handlers: + console: + class: logging.StreamHandler + level: DEBUG + formatter: simple + stream: ext://sys.stdout + + info_file_handler: + class: logging.handlers.RotatingFileHandler + level: INFO + formatter: simple + filename: info.log + maxBytes: 10485760 # 10MB + backupCount: 20 + encoding: utf8 + + error_file_handler: + class: logging.handlers.RotatingFileHandler + level: ERROR + formatter: simple + filename: errors.log + maxBytes: 10485760 # 10MB + backupCount: 20 + encoding: utf8 + + warning_file_handler: + class: logging.handlers.RotatingFileHandler + level: WARNING + formatter: simple + filename: warnings.log + maxBytes: 10485760 # 10MB + backupCount: 20 + encoding: utf8 + +loggers: + my_module: + level: INFO + handlers: [console] + propagate: no + +root: + level: INFO + handlers: [console, info_file_handler, error_file_handler, warning_file_handler] \ No newline at end of file diff --git a/pyews/endpoint/__init__.py b/pyews/endpoint/__init__.py new file mode 100644 index 0000000..ae5b350 --- /dev/null +++ b/pyews/endpoint/__init__.py @@ -0,0 +1,15 @@ +from .getsearchablemailboxes import GetSearchableMailboxes +from .getusersettings import GetUserSettings +from .searchmailboxes import SearchMailboxes +from .resolvenames import ResolveNames +from .deleteitem import DeleteItem +from .executesearch import ExecuteSearch +from .getinboxrules import GetInboxRules +from .getitem import GetItem +from .convertid import ConvertId +from .gethiddeninboxrules import GetHiddenInboxRules +from .getattachment import GetAttachment +from .syncfolderhierarchy import SyncFolderHierarchy +from .syncfolderitems import SyncFolderItems +from .createitem import CreateItem +from .getserviceconfiguration import GetServiceConfiguration \ No newline at end of file diff --git a/pyews/endpoint/adddelegate.py b/pyews/endpoint/adddelegate.py new file mode 100644 index 0000000..f214656 --- /dev/null +++ b/pyews/endpoint/adddelegate.py @@ -0,0 +1,81 @@ +from ..service import Operation +from ..utils.exceptions import UknownValueError +from ..utils.attributes import FOLDER_LIST, MESSAGE_ELEMENTS + + +class AddDelegate(Operation): + """AddDelegate EWS Operation adds one or more delegates + to a principal's mailbox and sets access permissions. + """ + + RESULTS_KEY = 'Items' + + __PERMISSIONS = [ + 'None', + 'Reviewer', + 'Author', + 'Editor' + ] + + def __init__(self, target_mailbox, delegate_to, inbox_permissions=None, calender_permissions=None, contacts_permissions=None): + """Adds the delegate_to to the provided target_mailbox with specific permission sets. + + Args: + target_mailbox (str): The mailbox in which permissions are added + delegate_to (str): The account that the permissions are assocaited with + inbox_permissions (str, optional): Mailbox permission set value. Defaults to None. + calender_permissions (str, optional): Calendar permission set value. Defaults to None. + contacts_permissions (str, optional): Contacts permission set value. Defaults to None. + """ + self.target_mailbox = target_mailbox + self.delegate_to = delegate_to + if inbox_permissions and inbox_permissions not in self.__PERMISSIONS: + UknownValueError(provided_value=inbox_permissions, known_values=self.__PERMISSIONS) + self.inbox_permissions = inbox_permissions if isinstance(inbox_permissions, list) else [inbox_permissions] + + if calender_permissions and calender_permissions not in self.__PERMISSIONS: + UknownValueError(provided_value=calender_permissions, known_values=self.__PERMISSIONS) + self.calender_permissions = calender_permissions if isinstance(calender_permissions, list) else [calender_permissions] + + if contacts_permissions and contacts_permissions not in self.__PERMISSIONS: + UknownValueError(provided_value=contacts_permissions, known_values=self.__PERMISSIONS) + self.contacts_permissions = contacts_permissions if isinstance(contacts_permissions, list) else [contacts_permissions] + + def __get_delegate_users(self): + return_list = [] + if self.inbox_permissions: + for item in self.inbox_permissions: + return_list.append( + self.T_NAMESPACE.InboxFolderPermissionLevel(item) + ) + if self.contacts_permissions: + for item in self.contacts_permissions: + return_list.append( + self.T_NAMESPACE.ContactsFolderPermissionLevel(item) + ) + if self.calender_permissions: + for item in self.calender_permissions: + return_list.append( + self.T_NAMESPACE.CalendarFolderPermissionLevel(item) + ) + return self.T_NAMESPACE.DelegateUser( + self.T_NAMESPACE.UserId( + self.T_NAMESPACE.PrimarySmtpAddress(self.delegate_to) + ), + self.T_NAMESPACE.DelegatePermissions( + *return_list + ), + self.T_NAMESPACE.ReceiveCopiesOfMeetingMessages('false'), + self.T_NAMESPACE.ViewPrivateItems('false') + ) + + def soap(self): + return self.M_NAMESPACE.AddDelegate( + self.M_NAMESPACE.Mailbox( + self.T_NAMESPACE.EmailAddress(self.target_mailbox) + ), + self.M_NAMESPACE.DelegateUsers( + self.__get_delegate_users() + ), + self.M_NAMESPACE.ReceiveCopiesOfMeetingMessages('DelegatesAndMe') + ) diff --git a/pyews/endpoint/convertid.py b/pyews/endpoint/convertid.py new file mode 100644 index 0000000..0c49ed2 --- /dev/null +++ b/pyews/endpoint/convertid.py @@ -0,0 +1,50 @@ +from ..service import Operation +from ..utils.exceptions import UknownValueError + + +class ConvertId(Operation): + """ConvertId EWS Operation converts item and folder + identifiers between formats. + """ + + RESULTS_KEY = '@Id' + ID_FORMATS = [ + 'EntryId', + 'EwsId', + 'EwsLegacyId', + 'HexEntryId', + 'OwaId', + 'StoreId' + ] + + def __init__(self, user, item_id, id_type, convert_to): + """Takes a specific user, item_id, id_type, and the + desired format to conver to as inputs. + + Args: + user (str): The mailbox that the ID is associated with + item_id (str): The item ID + id_type (str): The Item ID type + convert_to (str): The format to conver the Item ID to + + Raises: + UknownValueError: One or more provided values is unknown + """ + self.user = user + self.item_id = item_id + if id_type not in self.ID_FORMATS: + UknownValueError(provided_value=id_type, known_values=self.ID_FORMATS) + if convert_to not in self.ID_FORMATS: + UknownValueError(provided_value=convert_to, known_values=self.ID_FORMATS) + self.id_type = id_type + self.convert_to = convert_to + + def soap(self): + return self.M_NAMESPACE.ConvertId( + self.M_NAMESPACE.SourceIds( + self.T_NAMESPACE.AlternateId( + Format=self.id_type, Id=self.item_id, Mailbox=self.user + ) + ), + DestinationFormat=self.convert_to + ) diff --git a/pyews/endpoint/createitem.py b/pyews/endpoint/createitem.py new file mode 100644 index 0000000..ea8d5d9 --- /dev/null +++ b/pyews/endpoint/createitem.py @@ -0,0 +1,75 @@ +from ..service import Operation +from ..utils.exceptions import UknownValueError +from ..utils.attributes import FOLDER_LIST, MESSAGE_ELEMENTS + + +class CreateItem(Operation): + """CreateItem EWS Operation is used to create e-mail messages + """ + + RESULTS_KEY = 'Items' + MESSAGE_DISPOSITION = [ + 'SaveOnly', + 'SendOnly', + 'SendAndSaveCopy' + ] + BODY_TYPES = [ + 'Best', + 'HTML', + 'Text' + ] + + __MESSSAGE_ELEMENTS = [] + + def __init__(self, type='message', message_disposition='SendAndSaveCopy', save_item_to='sentitems', **kwargs): + """Creates an e-mail message + + Args: + type (str, optional): The type of item to create. Defaults to 'message'. + message_disposition (str, optional): The action to take when the item is created. Defaults to 'SendAndSaveCopy'. + save_item_to (str, optional): Where to save the created e-mail message. Defaults to 'sentitems'. + """ + self.__process_kwargs(kwargs) + self.type = type + if message_disposition not in self.MESSAGE_DISPOSITION: + UknownValueError(provided_value=message_disposition, known_values=self.MESSAGE_DISPOSITION) + self.message_disposition = message_disposition + if save_item_to not in FOLDER_LIST: + self.__save_item_to = self.T_NAMESPACE.FolderId(Id=save_item_to) + else: + self.__save_item_to = self.T_NAMESPACE.DistinguishedFolderId(Id=save_item_to) + + def __create_message(self): + return self.T_NAMESPACE.Message( + *self.__MESSSAGE_ELEMENTS + ) + + def __process_kwargs(self, kwargs): + for key,val in kwargs.items(): + for item in self._get_recursively(MESSAGE_ELEMENTS, key): + if isinstance(item, list): + if val in item: + if key in ['ToRecipients', 'Sender', 'CcRecipients', 'BccRecipients']: + self.__MESSSAGE_ELEMENTS.append(self.T_NAMESPACE(key, self.T_NAMESPACE.Mailbox(self.T_NAMESPACE.EmailAddress(val)))) + else: + self.__MESSSAGE_ELEMENTS.append(self.T_NAMESPACE(key, val)) + else: + if key in MESSAGE_ELEMENTS: + if key in ['ToRecipients', 'Sender', 'CcRecipients', 'BccRecipients']: + self.__MESSSAGE_ELEMENTS.append(self.T_NAMESPACE(key, self.T_NAMESPACE.Mailbox(self.T_NAMESPACE.EmailAddress(val)))) + else: + self.__MESSSAGE_ELEMENTS.append(self.T_NAMESPACE(key, val)) + + def soap(self): + item = None + if self.type == 'message': + item = self.__create_message() + return self.M_NAMESPACE.CreateItem( + self.M_NAMESPACE.SavedItemFolderId( + self.__save_item_to + ), + self.M_NAMESPACE.Items( + item + ), + MessageDisposition=self.message_disposition + ) diff --git a/pyews/endpoint/deleteitem.py b/pyews/endpoint/deleteitem.py new file mode 100644 index 0000000..3849f57 --- /dev/null +++ b/pyews/endpoint/deleteitem.py @@ -0,0 +1,34 @@ +from ..service import Operation +from ..utils.exceptions import UknownValueError + + +class DeleteItem(Operation): + """DeleteItem EWS Operation deletes items in the Exchange store. + """ + + DELETE_TYPES = ['HardDelete', 'SoftDelete', 'MoveToDeletedItems'] + + def __init__(self, item_id, delete_type='MoveToDeletedItems'): + """Deletes the provided Item ID from an Exchange store. + + Args: + item_id (str): The Item ID to delete + delete_type (str, optional): The delete type when deleting the item. Defaults to 'MoveToDeletedItems'. + """ + if not isinstance(item_id, list): + item_id = [item_id] + self.item_id = item_id + if delete_type and delete_type not in self.DELETE_TYPES: + UknownValueError(provided_value=delete_type, known_values=self.DELETE_TYPES) + self.delete_type = delete_type + + def soap(self): + item_id_list = [] + for item in self.item_id: + item_id_list.append(self.T_NAMESPACE.ItemId(Id=item)) + return self.M_NAMESPACE.DeleteItem( + self.M_NAMESPACE.ItemIds( + item_id_list + ), + **{'DeleteType':self.delete_type} + ) diff --git a/pyews/endpoint/executesearch.py b/pyews/endpoint/executesearch.py new file mode 100644 index 0000000..51a67c9 --- /dev/null +++ b/pyews/endpoint/executesearch.py @@ -0,0 +1,38 @@ +import uuid +from ..service import Operation + + +class ExecuteSearch(Operation): + """ExecuteSearch EWS Operation executes a search as an Outlook client. + """ + + RESULTS_KEY = 'Items' + + def __init__(self, query, result_row_count='25', max_results_count='-1'): + """Executes a search as an Outlook client for the authenticated user. + + Args: + query (str): The query to search for. + result_row_count (str, optional): The row count of results. Defaults to '25'. + max_results_count (str, optional): The max results count. -1 equals unlimited. Defaults to '-1'. + """ + self.query = query + self.result_row_count = result_row_count + self.max_results_count = max_results_count + + def soap(self): + self.session_id = '{id}'.format(id=str(uuid.uuid4()).upper()) + return self.M_NAMESPACE.ExecuteSearch( + self.M_NAMESPACE.ApplicationId('Outlook'), + self.M_NAMESPACE.Scenario('MailSearch'), + self.M_NAMESPACE.SearchSessionId(self.session_id), + self.M_NAMESPACE.SearchScope( + self.T_NAMESPACE.PrimaryMailboxSearchScope( + self.T_NAMESPACE.IsDeepTraversal('true') + ) + ), + self.M_NAMESPACE.Query(self.query), + self.M_NAMESPACE.ResultRowCount(self.result_row_count), + self.M_NAMESPACE.MaxResultsCountHint(self.max_results_count), + self.M_NAMESPACE.ItemTypes('MailItems') + ) diff --git a/pyews/endpoint/expanddl.py b/pyews/endpoint/expanddl.py new file mode 100644 index 0000000..260ca38 --- /dev/null +++ b/pyews/endpoint/expanddl.py @@ -0,0 +1,23 @@ +from ..service import Operation + + +class ExpandDL(Operation): + """ExpandDL EWS Operation expands the provided distribution list. + """ + + RESULTS_KEY = 'Items' + + def __init__(self, user): + """Expands the provided distribution list + + Args: + user (str): The distribution list to expand + """ + self.user = user + + def soap(self): + return self.M_NAMESPACE.ExpandDL( + self.M_NAMESPACE.Mailbox( + self.T_NAMESPACE.EmailAddress(self.user) + ) + ) diff --git a/pyews/endpoint/getattachment.py b/pyews/endpoint/getattachment.py new file mode 100644 index 0000000..c3f29b0 --- /dev/null +++ b/pyews/endpoint/getattachment.py @@ -0,0 +1,27 @@ +from ..service import Operation + + +class GetAttachment(Operation): + """GetAttachment EWS Operation retrieves the provided attachment ID. + """ + + RESULTS_KEY = 'Attachments' + + def __init__(self, attachment_id): + """Retrieves the provided attachment_id + + Args: + attachment_id (str): The attachment_id to retrieve. + """ + self.attachment_id = attachment_id + + def soap(self): + return self.M_NAMESPACE.GetAttachment( + self.M_NAMESPACE.AttachmentShape( + self.T_NAMESPACE.IncludeMimeContent('true'), + self.T_NAMESPACE.BodyType('Best') + ), + self.M_NAMESPACE.AttachmentIds( + self.T_NAMESPACE.AttachmentId(Id=self.attachment_id) + ) + ) diff --git a/pyews/endpoint/gethiddeninboxrules.py b/pyews/endpoint/gethiddeninboxrules.py new file mode 100644 index 0000000..65a78a3 --- /dev/null +++ b/pyews/endpoint/gethiddeninboxrules.py @@ -0,0 +1,37 @@ +from ..service import Operation + + +class GetHiddenInboxRules(Operation): + """FindItem EWS Operation attempts to find + hidden inbox rules based on ExtendedFieldURI properties. + """ + + RESULTS_KEY = 'Items' + + def soap(self): + return self.M_NAMESPACE.FindItem( + self.M_NAMESPACE.ItemShape( + self.T_NAMESPACE.BaseShape('IdOnly'), + self.T_NAMESPACE.AdditionalProperties( + self.T_NAMESPACE.ExtendedFieldURI(PropertyTag="0x65EC", PropertyType="String"), + self.T_NAMESPACE.ExtendedFieldURI(PropertyTag="0x0E99", PropertyType="Binary"), + self.T_NAMESPACE.ExtendedFieldURI(PropertyTag="0x0E9A", PropertyType="Binary"), + self.T_NAMESPACE.ExtendedFieldURI(PropertyTag="0x65E9", PropertyType="Integer"), + self.T_NAMESPACE.ExtendedFieldURI(PropertyTag="0x6800", PropertyType="String"), + self.T_NAMESPACE.ExtendedFieldURI(PropertyTag="0x65EB", PropertyType="String"), + self.T_NAMESPACE.ExtendedFieldURI(PropertyTag="0x3FEA", PropertyType="Boolean"), + self.T_NAMESPACE.ExtendedFieldURI(PropertyTag="0x6645", PropertyType="Binary"), + ) + ), + self.M_NAMESPACE.Restriction( + self.T_NAMESPACE.IsEqualTo( + self.T_NAMESPACE.FieldURI(FieldURI="item:ItemClass"), + self.T_NAMESPACE.FieldURIOrConstant( + self.T_NAMESPACE.Constant(Value="IPM.Rule.Version2.Message") + ) + ) + ), + self.M_NAMESPACE.ParentFolderIds( + self.T_NAMESPACE.DistinguishedFolderId(Id="inbox") + ), + Traversal="Shallow") diff --git a/pyews/endpoint/getinboxrules.py b/pyews/endpoint/getinboxrules.py new file mode 100644 index 0000000..f227314 --- /dev/null +++ b/pyews/endpoint/getinboxrules.py @@ -0,0 +1,23 @@ +from ..service import Operation + + +class GetInboxRules(Operation): + """GetInboxRules EWS Operation retrieves inbox rules. + """ + + RESULTS_KEY = 'GetInboxRulesResponse' + + def __init__(self, user=None): + """Retrieves inbox rules for the authenticated or specified user. + + Args: + user (str, optional): The user to retrieve inbox rules for. Defaults to authenticated user. + """ + self.__user = user + + def soap(self): + if not self.__user: + self.__user = self.credentials[0] + return self.M_NAMESPACE.GetInboxRules( + self.M_NAMESPACE.MailboxSmtpAddress(self.__user) + ) diff --git a/pyews/endpoint/getitem.py b/pyews/endpoint/getitem.py new file mode 100644 index 0000000..279e046 --- /dev/null +++ b/pyews/endpoint/getitem.py @@ -0,0 +1,55 @@ +from ..service import Operation +from ..utils.exceptions import UknownValueError + + +class GetItem(Operation): + """GetItem EWS Operation retrieves details about an item. + """ + + RESULTS_KEY = 'Items' + BASE_SHAPES = [ + 'IdOnly', + 'Default', + 'AllProperties' + ] + BODY_TYPES = [ + 'Best', + 'HTML', + 'Text' + ] + + def __init__(self, item_id, change_key=None, base_shape='AllProperties', include_mime_content=True, body_type='Best'): + """Retrieves details about a provided item id + + Args: + item_id (str): The item id you want to get information about. + change_key (str, optional): The change key of the item. Defaults to None. + base_shape (str, optional): The base shape of the returned item. Defaults to 'AllProperties'. + include_mime_content (bool, optional): Whether or not to include MIME content. Defaults to True. + body_type (str, optional): The item body type. Defaults to 'Best'. + """ + self.include_mime_content = include_mime_content + if base_shape not in self.BASE_SHAPES: + UknownValueError(provided_value=base_shape, known_values=self.BASE_SHAPES) + self.base_shape = base_shape + if body_type not in self.BODY_TYPES: + UknownValueError(provided_value=body_type, known_values=self.BODY_TYPES) + self.body_type = body_type + self.item_id = item_id + self.change_key = change_key + + def soap(self): + if self.change_key: + item_id_string = self.T_NAMESPACE.ItemId(Id=self.item_id, ChangeKey=self.change_key) + else: + item_id_string = self.T_NAMESPACE.ItemId(Id=self.item_id) + return self.M_NAMESPACE.GetItem( + self.M_NAMESPACE.ItemShape( + self.T_NAMESPACE.BaseShape(self.base_shape), + self.T_NAMESPACE.IncludeMimeContent(str(self.include_mime_content).lower()), + self.T_NAMESPACE.BodyType(self.body_type) + ), + self.M_NAMESPACE.ItemIds( + item_id_string + ) + ) diff --git a/pyews/endpoint/getsearchablemailboxes.py b/pyews/endpoint/getsearchablemailboxes.py new file mode 100644 index 0000000..009ef95 --- /dev/null +++ b/pyews/endpoint/getsearchablemailboxes.py @@ -0,0 +1,26 @@ +from ..service import Operation + + +class GetSearchableMailboxes(Operation): + """GetSearchableMailboxes EWS Operation retrieves a + list of searchable mailboxes the authenticated user has + access to search. + """ + + RESULTS_KEY = 'SearchableMailbox' + + def __init__(self, search_filter=None, expand_group_memberhip=True): + """Retrieves all searchable mailboxes the authenticated user has access to search. + + Args: + search_filter (str, optional): A search filter. Typically used to search specific distribution groups. Defaults to None. + expand_group_memberhip (bool, optional): Whether or not to expand group memberships. Defaults to True. + """ + self.search_filter = self.M_NAMESPACE.SearchFilter(search_filter) if search_filter else self.M_NAMESPACE.SearchFilter() + self.expand_group_membership = self.M_NAMESPACE.ExpandGroundMembership(str(expand_group_memberhip)) + + def soap(self): + return self.M_NAMESPACE.GetSearchableMailboxes( + self.search_filter, + self.expand_group_membership + ) diff --git a/pyews/endpoint/getserviceconfiguration.py b/pyews/endpoint/getserviceconfiguration.py new file mode 100644 index 0000000..d2e7b2a --- /dev/null +++ b/pyews/endpoint/getserviceconfiguration.py @@ -0,0 +1,59 @@ +from ..service import Operation +from ..utils.exceptions import UknownValueError +from ..utils.attributes import FOLDER_LIST, MESSAGE_ELEMENTS + + +class GetServiceConfiguration(Operation): + """GetServiceConfiguration EWS Operation retrieves + service configuration details + """ + + RESULTS_KEY = 'ServiceConfigurationResponseMessageType' + CONFIGURATION_NAMES = [ + 'MailTips', + 'UnifiedMessagingConfiguration', + 'ProtectionRules' + ] + + def __init__(self, configuration_name=None, acting_as=None): + """Retrieves service configuration details. + Default will attempt to retrieve them all. + + Args: + configuration_name (list, optional): The name of one or more configuration items. Defaults to 'MailTips','UnifiedMessagingConfiguration','ProtectionRules'. + acting_as (str, optional): If provided, will attempt to make call using provided user. Defaults to None. + + Raises: + UknownValueError: Unknown value was provided + """ + if configuration_name and configuration_name not in self.CONFIGURATION_NAMES: + UknownValueError(provided_value=configuration_name, known_values=self.CONFIGURATION_NAMES) + if configuration_name: + self.configuration_name = configuration_name if isinstance(configuration_name, list) else [configuration_name] + else: + self.configuration_name = self.CONFIGURATION_NAMES + self.acting_as = acting_as + + def __get_configuration_name(self): + return_list = [] + for item in self.configuration_name: + return_list.append(self.M_NAMESPACE.ConfigurationName(item)) + return return_list + + def soap(self): + if self.acting_as: + return self.M_NAMESPACE.GetServiceConfiguration( + self.M_NAMESPACE.ActingAs( + self.T_NAMESPACE.EmailAddress(self.acting_as), + self.T_NAMESPACE.RoutingType('SMTP') + ), + self.M_NAMESPACE.RequestedConfiguration( + *self.__get_configuration_name() + ) + ) + else: + return self.M_NAMESPACE.GetServiceConfiguration( + self.M_NAMESPACE.RequestedConfiguration( + *self.__get_configuration_name() + ) + ) diff --git a/pyews/endpoint/getusersettings.py b/pyews/endpoint/getusersettings.py new file mode 100644 index 0000000..960248c --- /dev/null +++ b/pyews/endpoint/getusersettings.py @@ -0,0 +1,42 @@ +from ..service.autodiscover import Autodiscover + + +class GetUserSettings(Autodiscover): + """GetUserSettings EWS Autodiscover endpoint + retrieves the authenticated or provided users settings + """ + + RESULTS_KEY = 'UserSettings' + + def __init__(self, user=None): + """Retrieves the user settings for the authenticated or provided user. + + Args: + user (str, optional): A user to retrieve user settings for. Defaults to None. + """ + self.user = user + + def soap(self): + if not self.user: + self.user = self.credentials[0] + return self.A_NAMESPACE.GetUserSettingsRequestMessage( + self.A_NAMESPACE.Request( + self.A_NAMESPACE.Users( + self.A_NAMESPACE.User( + self.A_NAMESPACE.Mailbox(self.user) + ) + ), + self.A_NAMESPACE.RequestedSettings( + self.A_NAMESPACE.Setting('InternalEwsUrl'), + self.A_NAMESPACE.Setting('ExternalEwsUrl'), + self.A_NAMESPACE.Setting('UserDisplayName'), + self.A_NAMESPACE.Setting('UserDN'), + self.A_NAMESPACE.Setting('UserDeploymentId'), + self.A_NAMESPACE.Setting('InternalMailboxServer'), + self.A_NAMESPACE.Setting('MailboxDN'), + self.A_NAMESPACE.Setting('ActiveDirectoryServer'), + self.A_NAMESPACE.Setting('MailboxDN'), + self.A_NAMESPACE.Setting('EwsSupportedSchemas'), + ) + ), + ) diff --git a/pyews/endpoint/resolvenames.py b/pyews/endpoint/resolvenames.py new file mode 100644 index 0000000..ee05665 --- /dev/null +++ b/pyews/endpoint/resolvenames.py @@ -0,0 +1,25 @@ +from ..service import Operation + + +class ResolveNames(Operation): + """ResolveNames EWS Operation attempts + to resolve the authenticated or provided name + """ + + RESULTS_KEY = 'ResolutionSet' + + def __init__(self, user=None): + """Resolves the authenticated or provided name + + Args: + user (str, optional): A user to attempt to resolve. Defaults to None. + """ + self.user = user + + def soap(self): + if not self.user: + self.user = self.credentials[0] + return self.M_NAMESPACE.ResolveNames( + self.M_NAMESPACE.UnresolvedEntry(self.user), + **{'ReturnFullContactData':"true"} + ) diff --git a/pyews/endpoint/searchmailboxes.py b/pyews/endpoint/searchmailboxes.py new file mode 100644 index 0000000..1c6b002 --- /dev/null +++ b/pyews/endpoint/searchmailboxes.py @@ -0,0 +1,53 @@ +from ..service import Operation +from ..utils.exceptions import UknownValueError + + +class SearchMailboxes(Operation): + """SearchMailboxes EWS Operation will search using a query on one or more provided + reference id's + """ + + RESULTS_KEY = 'SearchPreviewItem' + SEARCH_SCOPES = ['All', 'PrimaryOnly', 'ArchiveOnly'] + + def __init__(self, query, reference_id, search_scope='All'): + """Searches one or more reference id's using the provided query and search_scope. + + Args: + query (str): The Advanced Query Syntax (AQS) to search with. + reference_id (list): One or more mailbox reference Id's + search_scope (str, optional): The search scope. Defaults to 'All'. + + Raises: + UknownValueError: The provided search scope is unknown. + """ + self.query = query + if not isinstance(reference_id, list): + reference_id = [reference_id] + self.reference_id = reference_id + if search_scope not in self.SEARCH_SCOPES: + raise UknownValueError('The search_scope ({}) you provided is not one of {}'.format(search_scope, ','.join([x for x in self.SEARCH_SCOPES]))) + if search_scope in self.SEARCH_SCOPES: + self.scope = search_scope + + def soap(self): + return self.M_NAMESPACE.SearchMailboxes( + self.M_NAMESPACE.SearchQueries( + self.T_NAMESPACE.MailboxQuery( + self.T_NAMESPACE.Query(self.query), + self.T_NAMESPACE.MailboxSearchScopes(*self.__get_search_scope()) + ) + ), + self.M_NAMESPACE.ResultType('PreviewOnly') + ) + + def __get_search_scope(self): + mailbox_soap_element = [] + for item in self.reference_id: + mailbox_soap_element.append( + self.T_NAMESPACE.MailboxSearchScope( + self.T_NAMESPACE.Mailbox(item), + self.T_NAMESPACE.SearchScope(self.scope) + ) + ) + return mailbox_soap_element diff --git a/pyews/endpoint/syncfolderhierarchy.py b/pyews/endpoint/syncfolderhierarchy.py new file mode 100644 index 0000000..8e38fb4 --- /dev/null +++ b/pyews/endpoint/syncfolderhierarchy.py @@ -0,0 +1,57 @@ +from ..service import Operation + + +class SyncFolderHierarchy(Operation): + """SyncFolderHierarchy EWS Operation will retrieve + a mailboxes folder hierarchy + """ + + RESULTS_KEY = 'Changes' + FOLDER_LIST = [ + 'msgfolderroot', + 'calendar', + 'contacts', + 'deleteditems', + 'drafts', + 'inbox', + 'journal', + 'notes', + 'outbox', + 'sentitems', + 'tasks', + 'junkemail', + 'searchfolders', + 'voicemail', + 'recoverableitemsdeletions', + 'recoverableitemsversions', + 'recoverableitemspurges', + 'recipientcache', + 'quickcontacts', + 'conversationhistory', + 'todosearch', + 'mycontacts', + 'imcontactlist', + 'peopleconnect', + 'favorites' + ] + + def __init__(self, well_known_folder_name=None): + """Retrieve the authenticated users mailbox folder hierarchy. + + Args: + well_known_folder_name (str, optional): The well known folder name. Defaults to all known folder names. + """ + self.attachment_id = well_known_folder_name + + def soap(self): + folder_id_list = [] + for folder in self.FOLDER_LIST: + folder_id_list.append(self.T_NAMESPACE.DistinguishedFolderId(Id=folder)) + return self.M_NAMESPACE.SyncFolderHierarchy( + self.M_NAMESPACE.FolderShape( + self.T_NAMESPACE.BaseShape('AllProperties') + ), + self.M_NAMESPACE.SyncFolderId( + *folder_id_list + ) + ) diff --git a/pyews/endpoint/syncfolderitems.py b/pyews/endpoint/syncfolderitems.py new file mode 100644 index 0000000..ce303a3 --- /dev/null +++ b/pyews/endpoint/syncfolderitems.py @@ -0,0 +1,411 @@ +from ..service import Operation + + +class SyncFolderItems(Operation): + """SyncFolderItems EWS Operation retrieves details about folder items + """ + + RESULTS_KEY = 'Changes' + FIELD_ACTION_TYPE_MAP = { + 'create': [ + 'Item', + 'Message', + 'CalendarItem', + 'Contact', + 'DistributionList', + 'MeetingMessage', + 'MeetingRequest', + 'MeetingResponse', + 'MeetingCancellation', + 'Task' + ], + 'update': [ + 'Item', + 'Message', + 'CalendarItem', + 'Contact', + 'DistributionList', + 'MeetingMessage', + 'MeetingRequest', + 'MeetingResponse', + 'MeetingCancellation', + 'Task' + ], + 'delete': [ + 'ItemId' + ], + 'readflagchange': [ + 'ItemId', + 'IsRead' + ] + } + FIELD_URI_PROPERTIES = [ + "folder:FolderId", + "folder:ParentFolderId", + "folder:DisplayName", + "folder:UnreadCount", + "folder:TotalCount", + "folder:ChildFolderCount", + "folder:FolderClass", + "folder:SearchParameters", + "folder:ManagedFolderInformation", + "folder:PermissionSet", + "folder:EffectiveRights", + "folder:SharingEffectiveRights", + "item:ItemId", + "item:ParentFolderId", + "item:ItemClass", + "item:MimeContent", + "item:Attachments", + "item:Subject", + "item:DateTimeReceived", + "item:Size", + "item:Categories", + "item:HasAttachments", + "item:Importance", + "item:InReplyTo", + "item:InternetMessageHeaders", + "item:IsAssociated", + "item:IsDraft", + "item:IsFromMe", + "item:IsResend", + "item:IsSubmitted", + "item:IsUnmodified", + "item:DateTimeSent", + "item:DateTimeCreated", + "item:Body", + "item:ResponseObjects", + "item:Sensitivity", + "item:ReminderDueBy", + "item:ReminderIsSet", + "item:ReminderNextTime", + "item:ReminderMinutesBeforeStart", + "item:DisplayTo", + "item:DisplayCc", + "item:Culture", + "item:EffectiveRights", + "item:LastModifiedName", + "item:LastModifiedTime", + "item:ConversationId", + "item:UniqueBody", + "item:Flag", + "item:StoreEntryId", + "item:InstanceKey", + "item:NormalizedBody", + "item:EntityExtractionResult", + "itemPolicyTag", + "item:ArchiveTag", + "item:RetentionDate", + "item:Preview", + "item:NextPredictedAction", + "item:GroupingAction", + "item:PredictedActionReasons", + "item:IsClutter", + "item:RightsManagementLicenseData", + "item:BlockStatus", + "item:HasBlockedImages", + "item:WebClientReadFormQueryString", + "item:WebClientEditFormQueryString", + "item:TextBody", + "item:IconIndex", + "item:MimeContentUTF8", + "message:ConversationIndex", + "message:ConversationTopic", + "message:InternetMessageId", + "message:IsRead", + "message:IsResponseRequested", + "message:IsReadReceiptRequested", + "message:IsDeliveryReceiptRequested", + "message:ReceivedBy", + "message:ReceivedRepresenting", + "message:References", + "message:ReplyTo", + "message:From", + "message:Sender", + "message:ToRecipients", + "message:CcRecipients", + "message:BccRecipients", + "message:ApprovalRequestData", + "message:VotingInformation", + "message:ReminderMessageData", + "meeting:AssociatedCalendarItemId", + "meeting:IsDelegated", + "meeting:IsOutOfDate", + "meeting:HasBeenProcessed", + "meeting:ResponseType", + "meeting:ProposedStart", + "meeting:PropsedEnd", + "meetingRequest:MeetingRequestType", + "meetingRequest:IntendedFreeBusyStatus", + "meetingRequest:ChangeHighlights", + "calendar:Start", + "calendar:End", + "calendar:OriginalStart", + "calendar:StartWallClock", + "calendar:EndWallClock", + "calendar:StartTimeZoneId", + "calendar:EndTimeZoneId", + "calendar:IsAllDayEvent", + "calendar:LegacyFreeBusyStatus", + "calendar:Location", + "calendar:When", + "calendar:IsMeeting", + "calendar:IsCancelled", + "calendar:IsRecurring", + "calendar:MeetingRequestWasSent", + "calendar:IsResponseRequested", + "calendar:CalendarItemType", + "calendar:MyResponseType", + "calendar:Organizer", + "calendar:RequiredAttendees", + "calendar:OptionalAttendees", + "calendar:Resources", + "calendar:ConflictingMeetingCount", + "calendar:AdjacentMeetingCount", + "calendar:ConflictingMeetings", + "calendar:AdjacentMeetings", + "calendar:Duration", + "calendar:TimeZone", + "calendar:AppointmentReplyTime", + "calendar:AppointmentSequenceNumber", + "calendar:AppointmentState", + "calendar:Recurrence", + "calendar:FirstOccurrence", + "calendar:LastOccurrence", + "calendar:ModifiedOccurrences", + "calendar:DeletedOccurrences", + "calendar:MeetingTimeZone", + "calendar:ConferenceType", + "calendar:AllowNewTimeProposal", + "calendar:IsOnlineMeeting", + "calendar:MeetingWorkspaceUrl", + "calendar:NetShowUrl", + "calendar:UID", + "calendar:RecurrenceId", + "calendar:DateTimeStamp", + "calendar:StartTimeZone", + "calendar:EndTimeZone", + "calendar:JoinOnlineMeetingUrl", + "calendar:OnlineMeetingSettings", + "calendar:IsOrganizer", + "task:ActualWork", + "task:AssignedTime", + "task:BillingInformation", + "task:ChangeCount", + "task:Companies", + "task:CompleteDate", + "task:Contacts", + "task:DelegationState", + "task:Delegator", + "task:DueDate", + "task:IsAssignmentEditable", + "task:IsComplete", + "task:IsRecurring", + "task:IsTeamTask", + "task:Mileage", + "task:Owner", + "task:PercentComplete", + "task:Recurrence", + "task:StartDate", + "task:Status", + "task:StatusDescription", + "task:TotalWork", + "contacts:Alias", + "contacts:AssistantName", + "contacts:Birthday", + "contacts:BusinessHomePage", + "contacts:Children", + "contacts:Companies", + "contacts:CompanyName", + "contacts:CompleteName", + "contacts:ContactSource", + "contacts:Culture", + "contacts:Department", + "contacts:DisplayName", + "contacts:DirectoryId", + "contacts:DirectReports", + "contacts:EmailAddresses", + "contacts:FileAs", + "contacts:FileAsMapping", + "contacts:Generation", + "contacts:GivenName", + "contacts:ImAddresses", + "contacts:Initials", + "contacts:JobTitle", + "contacts:Manager", + "contacts:ManagerMailbox", + "contacts:MiddleName", + "contacts:Mileage", + "contacts:MSExchangeCertificate", + "contacts:Nickname", + "contacts:Notes", + "contacts:OfficeLocation", + "contacts:PhoneNumbers", + "contacts:PhoneticFullName", + "contacts:PhoneticFirstName", + "contacts:PhoneticLastName", + "contacts:Photo", + "contacts:PhysicalAddresses", + "contacts:PostalAddressIndex", + "contacts:Profession", + "contacts:SpouseName", + "contacts:Surname", + "contacts:WeddingAnniversary", + "contacts:UserSMIMECertificate", + "contacts:HasPicture", + "distributionlist:Members", + "postitem:PostedTime", + "conversation:ConversationId", + "conversation:ConversationTopic", + "conversation:UniqueRecipients", + "conversation:GlobalUniqueRecipients", + "conversation:UniqueUnreadSenders", + "conversation:GlobalUniqueUnreadSenders", + "conversation:UniqueSenders", + "conversation:GlobalUniqueSenders", + "conversation:LastDeliveryTime", + "conversation:GlobalLastDeliveryTime", + "conversation:Categories", + "conversation:GlobalCategories", + "conversation:FlagStatus", + "conversation:GlobalFlagStatus", + "conversation:HasAttachments", + "conversation:GlobalHasAttachments", + "conversation:HasIrm", + "conversation:GlobalHasIrm", + "conversation:MessageCount", + "conversation:GlobalMessageCount", + "conversation:UnreadCount", + "conversation:GlobalUnreadCount", + "conversation:Size", + "conversation:GlobalSize", + "conversation:ItemClasses", + "conversation:GlobalItemClasses", + "conversation:Importance", + "conversation:GlobalImportance", + "conversation:ItemIds", + "conversation:GlobalItemIds", + "conversation:LastModifiedTime", + "conversation:InstanceKey", + "conversation:Preview", + "conversation:GlobalParentFolderId", + "conversation:NextPredictedAction", + "conversation:GroupingAction", + "conversation:IconIndex", + "conversation:GlobalIconIndex", + "conversation:DraftItemIds", + "conversation:HasClutter", + "persona:PersonaId", + "persona:PersonaType", + "persona:GivenName", + "persona:CompanyName", + "persona:Surname", + "persona:DisplayName", + "persona:EmailAddress", + "persona:FileAs", + "persona:HomeCity", + "persona:CreationTime", + "persona:RelevanceScore", + "persona:WorkCity", + "persona:PersonaObjectStatus", + "persona:FileAsId", + "persona:DisplayNamePrefix", + "persona:YomiCompanyName", + "persona:YomiFirstName", + "persona:YomiLastName", + "persona:Title", + "persona:EmailAddresses", + "persona:PhoneNumber", + "persona:ImAddress", + "persona:ImAddresses", + "persona:ImAddresses2", + "persona:ImAddresses3", + "persona:FolderIds", + "persona:Attributions", + "persona:DisplayNames", + "persona:Initials", + "persona:FileAses", + "persona:FileAsIds", + "persona:DisplayNamePrefixes", + "persona:GivenNames", + "persona:MiddleNames", + "persona:Surnames", + "persona:Generations", + "persona:Nicknames", + "persona:YomiCompanyNames", + "persona:YomiFirstNames", + "persona:YomiLastNames", + "persona:BusinessPhoneNumbers", + "persona:BusinessPhoneNumbers2", + "persona:HomePhones", + "persona:HomePhones2", + "persona:MobilePhones", + "persona:MobilePhones2", + "persona:AssistantPhoneNumbers", + "persona:CallbackPhones", + "persona:CarPhones", + "persona:HomeFaxes", + "persona:OrganizationMainPhones", + "persona:OtherFaxes", + "persona:OtherTelephones", + "persona:OtherPhones2", + "persona:Pagers", + "persona:RadioPhones", + "persona:TelexNumbers", + "persona:WorkFaxes", + "persona:Emails1", + "persona:Emails2", + "persona:Emails3", + "persona:BusinessHomePages", + "persona:School", + "persona:PersonalHomePages", + "persona:OfficeLocations", + "persona:BusinessAddresses", + "persona:HomeAddresses", + "persona:OtherAddresses", + "persona:Titles", + "persona:Departments", + "persona:CompanyNames", + "persona:Managers", + "persona:AssistantNames", + "persona:Professions", + "persona:SpouseNames", + "persona:Hobbies", + "persona:WeddingAnniversaries", + "persona:Birthdays", + "persona:Children", + "persona:Locations", + "persona:ExtendedProperties", + "persona:PostalAddress", + "persona:Bodies" + ] + + def __init__(self, folder_id, change_key=None): + """Retrieves details about a provided folder id. + + Args: + folder_id (str): The folder id to retrieve details about. + change_key (str, optional): The version key of the folder id. Defaults to None. + """ + self.folder_id = folder_id + self.change_key = change_key + + def soap(self): + if self.change_key: + folder_id = self.T_NAMESPACE.FolderId(Id=self.folder_id, ChangeKey=self.change_key) + else: + folder_id = self.T_NAMESPACE.FolderId(Id=self.folder_id) + return self.M_NAMESPACE.SyncFolderItems( + self.M_NAMESPACE.ItemShape( + self.T_NAMESPACE.BaseShape('AllProperties'), + self.T_NAMESPACE.IncludeMimeContent('true'), + self.T_NAMESPACE.BodyType('Best'), + self.T_NAMESPACE.FilterHtmlContent('false'), + self.T_NAMESPACE.ConvertHtmlCodePageToUTF8('false') + ), + self.M_NAMESPACE.SyncFolderId( + folder_id + ), + self.M_NAMESPACE.MaxChangesReturned('100'), + self.M_NAMESPACE.SyncScope('NormalAndAssociatedItems') + ) diff --git a/pyews/service/__init__.py b/pyews/service/__init__.py index c9a2e55..6ac2ef2 100644 --- a/pyews/service/__init__.py +++ b/pyews/service/__init__.py @@ -1 +1,2 @@ -from pyews.utils.logger import logging +from .autodiscover import Autodiscover +from .operation import Operation \ No newline at end of file diff --git a/pyews/service/autodiscover.py b/pyews/service/autodiscover.py new file mode 100644 index 0000000..0042247 --- /dev/null +++ b/pyews/service/autodiscover.py @@ -0,0 +1,47 @@ +from .base import Base, ElementMaker, abc, etree + + +class Autodiscover(Base): + """Autodiscover class inherits from the Base class + and defines namespaces, headers, and body + of an Autodiscover SOAP request + """ + + AUTODISCOVER_MAP = { + 'wsa': "http://www.w3.org/2005/08/addressing", + 'xsi': "http://www.w3.org/2001/XMLSchema-instance", + 'soap': "http://schemas.xmlsoap.org/soap/envelope/", + 'a': "http://schemas.microsoft.com/exchange/2010/Autodiscover" + } + AUTODISCOVER_NAMESPACE = ElementMaker( + namespace=Base.NAMESPACE_MAP['soap'], + nsmap=AUTODISCOVER_MAP + ) + BODY_ELEMENT = ElementMaker(namespace=AUTODISCOVER_MAP['soap']).Body + A_NAMESPACE = ElementMaker(namespace=AUTODISCOVER_MAP['a'], nsmap={'a': AUTODISCOVER_MAP['a']}) + WSA_NAMESPACE = ElementMaker(namespace=AUTODISCOVER_MAP['wsa'], nsmap={'wsa': AUTODISCOVER_MAP['wsa']}) + + @property + def to(self): + return self.credentials[0] + + def get(self, exchange_version): + self.__logger.info('Building Autodiscover SOAP request for {current}'.format(current=self.__class__.__name__)) + ENVELOPE = self.AUTODISCOVER_NAMESPACE.Envelope + HEADER = self.SOAP_NAMESPACE.Header + self.A_NAMESPACE = ElementMaker(namespace=self.AUTODISCOVER_MAP['a'], nsmap={'a': self.AUTODISCOVER_MAP['a']}) + self.envelope = ENVELOPE( + HEADER( + self.A_NAMESPACE.RequestedServerVersion(exchange_version), + self.WSA_NAMESPACE.Action('http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/{}'.format(self.__class__.__name__)), + self.WSA_NAMESPACE.To(self.to) + ), + self.BODY_ELEMENT( + self.soap() + ) + ) + return etree.tostring(self.envelope) + + @abc.abstractmethod + def soap(self): + raise NotImplementedError diff --git a/pyews/service/base.py b/pyews/service/base.py new file mode 100644 index 0000000..1e593e3 --- /dev/null +++ b/pyews/service/base.py @@ -0,0 +1,160 @@ +import abc +import requests +from lxml.builder import ElementMaker +from lxml import etree +from bs4 import BeautifulSoup + +from ..core import Core + + +class Base(Core): + """The Base class is used by all endpoints and network + communications. It defines the base structure of all namespaces. + It is inherited by the Autodiscover & Operation classes + """ + + SOAP_REQUEST_HEADER = {'content-type': 'text/xml; charset=UTF-8'} + NAMESPACE_MAP = { + 'soap': "http://schemas.xmlsoap.org/soap/envelope/", + 'm': "http://schemas.microsoft.com/exchange/services/2006/messages", + 't': "http://schemas.microsoft.com/exchange/services/2006/types", + 'a': "http://schemas.microsoft.com/exchange/2010/Autodiscover", + } + SOAP_MESSAGE_ELEMENT = ElementMaker( + namespace=NAMESPACE_MAP['soap'], + nsmap={ + 'soap': NAMESPACE_MAP['soap'], + 'm': NAMESPACE_MAP['m'], + 't': NAMESPACE_MAP['t'] + } + ) + SOAP_NAMESPACE = ElementMaker(namespace=NAMESPACE_MAP['soap'],nsmap={'soap': "http://schemas.xmlsoap.org/soap/envelope/"}) + M_NAMESPACE = ElementMaker(namespace=NAMESPACE_MAP['m'],nsmap={'m': "http://schemas.microsoft.com/exchange/services/2006/messages"}) + T_NAMESPACE = ElementMaker(namespace=NAMESPACE_MAP['t'], nsmap={'t': "http://schemas.microsoft.com/exchange/services/2006/types"}) + XML_ENVELOPE = SOAP_MESSAGE_ELEMENT = ElementMaker( + namespace=NAMESPACE_MAP['soap'], + nsmap={ + 'soap': NAMESPACE_MAP['soap'], + 'm': NAMESPACE_MAP['m'], + 't': NAMESPACE_MAP['t'], + 'xsi': "http://www.w3.org/2001/XMLSchema-instance", + 'xs': "http://www.w3.org/2001/XMLSchema" + } + ) + NULL_ELEMENT = ElementMaker() + HEADER_ELEMENT = ElementMaker( + namespace=NAMESPACE_MAP['soap'], + nsmap={ + 'soap': NAMESPACE_MAP['soap'], + 't': NAMESPACE_MAP['t'] + } + ) + BODY_ELEMENT = SOAP_MESSAGE_ELEMENT.Body + EXCHANGE_VERSION_ELEMENT = T_NAMESPACE.RequestServerVersion + + @property + def raw_xml(self): + return self.__raw_xml + + @raw_xml.setter + def raw_xml(self, value): + self.__raw_xml = value + + @abc.abstractmethod + def get(self): + raise NotImplementedError + + def __parse_convert_id_error_message(self, error_message): + result = error_message.split('Please use the ConvertId method to convert the Id from ')[1].split(' format.')[0] + return result.split(' to ') + + def __process_response(self, response): + self.__logger.debug('SOAP REQUEST: {}'.format(response)) + self.raw_xml = response + namespace = self.NAMESPACE_MAP + namespace_dict = {} + for key,val in namespace.items(): + namespace_dict[val] = None + return self.parse_response(response, namespace_dict=namespace_dict) + + def run(self): + """The Base class run method is used for all SOAP requests for + every endpoint defined + + Returns: + BeautifulSoup: Returns a BeautifulSoup object or None. + """ + for endpoint in Core.endpoints: + for version in Core.exchange_versions: + if self.__class__.__base__.__name__ == 'Operation' and 'autodiscover' in endpoint: + self.__logger.debug('{} == Operation so skipping endpoint {}'.format(self.__class__.__base__.__name__, endpoint)) + continue + elif self.__class__.__base__.__name__ == 'Autodiscover' and 'autodiscover' not in endpoint: + self.__logger.debug('{} == Autodiscover so skipping endpoint {}'.format(self.__class__.__base__.__name__, endpoint)) + continue + try: + self.__logger.info('Sending SOAP request to {}'.format(endpoint)) + response = requests.post( + url=endpoint, + data=self.get(version).decode("utf-8"), + headers=self.SOAP_REQUEST_HEADER, + auth=Core.credentials, + verify=True + ) + + self.__logger.debug('Response HTTP status code: %s', response.status_code) + self.__logger.debug('Response text: %s', response.text) + + parsed_response = BeautifulSoup(response.content, 'xml') + if not parsed_response.contents: + self.__logger.warning( + 'The server responded with empty content to POST-request ' + 'from {current}'.format(current=self.__class__.__name__)) + return + + response_code = getattr(parsed_response.find('ResponseCode'), 'string', None) + error_code = getattr(parsed_response.find('ErrorCode'), 'string', None) + message_text = getattr(parsed_response.find('MessageText'), 'string', None) + error_message = getattr(parsed_response.find('ErrorMessage'), 'string', None) + fault_message = getattr(parsed_response.find('faultcode'), 'string', None) + fault_string = getattr(parsed_response.find('faultstring'), 'string', None) + + if 'NoError' in (response_code, error_code): + return self.__process_response(parsed_response) + elif error_message: + self.__logger.warning( + 'The server responded with "{response_code}" ' + 'response code to POST-request from {current} with error message "{error_message}"'.format( + current=self.__class__.__name__, + error_message=error_message, + response_code=response_code)) + elif 'ErrorAccessDenied' in (response_code, error_code): + self.__logger.warning( + 'The server responded with "ErrorAccessDenied" ' + 'response code to POST-request from {current} with error message "{message_text}"'.format( + current=self.__class__.__name__, + message_text=message_text)) + return None + elif 'ErrorInvalidIdMalformed' in (response_code, error_code): + self.__logger.warning( + 'The server responded with "ErrorInvalidIdMalformed" ' + 'response code to POST-request from {current} with error message "{message_text}"'.format( + current=self.__class__.__name__, + message_text=message_text)) + if 'ConvertId' in message_text: + return self.__parse_convert_id_error_message(message_text) + elif fault_message or fault_string: + self.__logger.warning( + 'The server responded with a "{fault_message}" ' + 'to POST-request from {current} with error message "{fault_string}"'.format( + current=self.__class__.__name__, + fault_message=fault_message, + fault_string=fault_string)) + else: + self.__logger.warning( + 'The server responded with unknown "ResponseCode" ' + 'and "ErrorCode" from {current} with error message "{message_text}"'.format( + current=self.__class__.__name__, + message_text=message_text)) + except: + pass diff --git a/pyews/service/deleteitem.py b/pyews/service/deleteitem.py deleted file mode 100644 index 9c013b4..0000000 --- a/pyews/service/deleteitem.py +++ /dev/null @@ -1,134 +0,0 @@ -from ..core import Core -from ..utils.exchangeversion import ExchangeVersion -from ..utils.exceptions import DeleteTypeError - - -class DeleteItem(Core): - '''Deletes items (typically email messages) from a users mailboxes. - - Examples: - - To use any service class you must provide a UserConfiguration object first. - Like all service classes, you can access formatted properties from the EWS endpoint using the `response` property. - - If you want to move a single message to the `Deleted Items` folder then provide a string value of the message ID. - The default `delete_type` is to move a message to the `Deleted Items` folder. - - ```python - userconfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123' - ) - messageId = 'AAMkAGZjOTlkOWExLTM2MDEtNGI3MS04ZDJiLTllNzgwNDQxMThmMABGAAAAAABdQG8UG7qjTKf0wCVbqLyMBwC6DuFzUH4qRojG/OZVoLCfAAAAAAEMAAC6DuFzUH4qRojG/OZVoLCfAAAu4Y9UAAA=' - deleteItem = DeleteItem(userconfig).run(message_id) - ``` - - If you want to HardDelete a single message then provide a string value of the message ID and specify the `delete_type` as `HardDelete`: - - ```python - from pyews import UserConfiguration - from pyews import DeleteItem - - userconfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123' - ) - - messageId = 'AAMkAGZjOTlkOWExLTM2MDEtNGI3MS04ZDJiLTllNzgwNDQxMThmMABGAAAAAABdQG8UG7qjTKf0wCVbqLyMBwC6DuFzUH4qRojG/OZVoLCfAAAAAAEMAAC6DuFzUH4qRojG/OZVoLCfAAAu4Y9UAAA=' - deleteItem = DeleteItem(userConfig).run(message_id, delete_type='HardDelete') - ``` - - Args: - messageId (list or str): An email MessageId to delete - userconfiguration (UserConfiguration): A UserConfiguration object created using the UserConfiguration class - delete_type (str, optional): Defaults to MoveToDeletedItems. Specify the DeleteType. Available options are ['HardDelete', 'SoftDelete', 'MoveToDeletedItems'] - - Raises: - SoapAccessDeniedError: Access is denied when attempting to use Exchange Web Services endpoint - SoapResponseHasError: An error occurred when parsing the SOAP response - ObjectType: An incorrect object type has been used - ''' - - DELETE_TYPES = ['HardDelete', 'SoftDelete', 'MoveToDeletedItems'] - - def __init__(self, userconfiguration): - super(DeleteItem, self).__init__(userconfiguration) - - def __parse_response(self, value): - '''Creates and sets a response object - - Args: - value (str): The raw response from a SOAP request - ''' - return_list = [] - if value.find('ResponseCode').string != 'NoError': - for item in value.find_all('DeleteItemResponseMessage'): - return_list.append({ - 'MessageText': item.MessageText.string, - 'ResponseCode': item.ResponseCode.string, - 'DescriptiveLinkKey': item.DescriptiveLinkKey.string - }) - else: - return_list.append({ - 'MessageText': 'Successfull' - }) - return return_list - - def run(self, message_id, delete_type=DELETE_TYPES[2]): - if delete_type in self.DELETE_TYPES: - self.delete_type = delete_type - else: - raise DeleteTypeError('You must provide one of the following delete types: {}'.format(self.DELETE_TYPES)) - self.raw_xml = self.invoke(self.soap(message_id)) - return self.__parse_response(self.raw_xml) - - def soap(self, item): - '''Creates the SOAP XML message body - - Args: - item (str or list): A single or list of message ids to delete - - Returns: - str: Returns the SOAP XML request body - ''' - if self.userconfiguration.impersonation: - impersonation_header = self.userconfiguration.impersonation.header - else: - impersonation_header = '' - - delete_item_soap_element = self.__delete_item_soap_string(item) - return ''' - - - - {header} - - - - - {soap_element} - - - -'''.format( - version=self.userconfiguration.exchangeVersion, - header=impersonation_header, - type=self.delete_type, - soap_element=delete_item_soap_element - ) - - def __delete_item_soap_string(self, item): - '''Creates a ItemId XML element from a single or list of items - - Returns: - str: Returns the ItemId SOAP XML element(s) - ''' - item_soap_string = '' - if isinstance(item, list): - for i in item: - item_soap_string += ''''''.format(i) - else: - item_soap_string = ''''''.format(item) - return item_soap_string diff --git a/pyews/service/findhiddeninboxrules.py b/pyews/service/findhiddeninboxrules.py deleted file mode 100644 index 2103a36..0000000 --- a/pyews/service/findhiddeninboxrules.py +++ /dev/null @@ -1,101 +0,0 @@ -from ..core import Core - - -class FindHiddenInboxRules(Core): - '''Retrieves hidden inbox (mailbox) rules for a users email address specificed by impersonation headers. - - Args: - userconfiguration (UserConfiguration): A UserConfiguration object created using the UserConfiguration class - ''' - - def __init__(self, userconfiguration): - super(FindHiddenInboxRules, self).__init__(userconfiguration) - - def __process_rule_properties(self, item): - if item: - return_dict = {} - for prop in item: - if prop.name != 'Conditions' and prop.name != 'Actions': - if prop.name not in return_dict: - return_dict[prop.name] = prop.string - for condition in item.find('Conditions'): - if 'conditions' not in return_dict: - return_dict['conditions'] = [] - return_dict['conditions'].append({ - condition.name: condition.string - }) - for action in item.find('Actions'): - if 'actions' not in return_dict: - return_dict['actions'] = [] - return_dict['actions'].append({ - action.name: action.string - }) - return return_dict - - def __parse_response(self, value): - '''Creates and sets a response object - - Args: - value (str): The raw response from a SOAP request - ''' - return_list = [] - if value.find('ResponseCode').string == 'NoError': - for item in value.find('InboxRules'): - if item.name == 'Rule' and item: - return_list.append(self.__process_rule_properties(item)) - return return_list - - def run(self): - self.raw_xml = self.invoke(self.soap()) - return self.__parse_response(self.raw_xml) - - def soap(self): - '''Creates the SOAP XML message body - - Returns: - str: Returns the SOAP XML request body - ''' - if (self.userconfiguration.impersonation): - impersonation_header = self.userconfiguration.impersonation.header - else: - impersonation_header = '' - - return ''' - - - - {header} - - - - - IdOnly - - - - - - - - - - - - - - - - - - - - - - - - - - '''.format(version=self.userconfiguration.exchangeVersion, header=impersonation_header) diff --git a/pyews/service/getinboxrules.py b/pyews/service/getinboxrules.py deleted file mode 100644 index 827a262..0000000 --- a/pyews/service/getinboxrules.py +++ /dev/null @@ -1,112 +0,0 @@ -from ..core import Core - - -class GetInboxRules(Core): - '''Retrieves inbox (mailbox) rules for a specified email address. - - Examples: - - To use any service class you must provide a UserConfiguration object first. - Like all service classes, you can access formatted properties from the EWS endpoint using the `response` property. - - If you want to retrieve the inbox rules for a specific email address you must provide it when creating a GetInboxRules object. - - ```python - from pyews import UserConfiguration - from pyews import GetInboxRules - - userconfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123' - ) - - inboxRules = GetInboxRules(userconfig).run('first.last@company.com') - ``` - - Args: - smtp_address (str): The email address you want to get inbox rules for - userconfiguration (UserConfiguration): A UserConfiguration object created using the UserConfiguration class - ''' - - def __init__(self, userconfiguration): - super(GetInboxRules, self).__init__(userconfiguration) - - def __process_rule_properties(self, item): - if item: - return_dict = {} - for prop in item: - if prop.name != 'Conditions' and prop.name != 'Actions': - if prop.name not in return_dict: - return_dict[prop.name] = prop.string - if item.find('Conditions'): - for condition in item.find('Conditions'): - if 'conditions' not in return_dict: - return_dict['conditions'] = [] - return_dict['conditions'].append({ - condition.name: condition.string - }) - if item.find('Actions'): - for action in item.find('Actions'): - if 'actions' not in return_dict: - return_dict['actions'] = [] - return_dict['actions'].append({ - action.name: action.string - }) - return return_dict - - def __parse_response(self, value): - '''Creates and sets a response object - - Args: - value (str): The raw response from a SOAP request - ''' - return_list = [] - if value.find('ResponseCode').string == 'NoError': - for item in value.find('InboxRules'): - if item.name == 'Rule' and item: - return_list.append(self.__process_rule_properties(item)) - if self.hidden_rules: - from .findhiddeninboxrules import FindHiddenInboxRules - return_list.append(FindHiddenInboxRules(self.userconfiguration).run()) - return return_list - - def run(self, smtp_address, hidden_rules=False): - self.hidden_rules = hidden_rules - self.email_address = smtp_address - self.raw_xml = self.invoke(self.soap()) - return self.__parse_response(self.raw_xml) - - def soap(self): - '''Creates the SOAP XML message body - - Args: - email_address (str): A single email addresses you want to GetInboxRules for - - Returns: - str: Returns the SOAP XML request body - ''' - if self.userconfiguration.impersonation: - impersonation_header = self.userconfiguration.impersonation.header - else: - impersonation_header = '' - - return ''' - - - - {header} - - - - {email} - - - - '''.format( - version=self.userconfiguration.exchangeVersion, - header=impersonation_header, - email=self.email_address - ) diff --git a/pyews/service/getitem.py b/pyews/service/getitem.py deleted file mode 100644 index 7ca0d7a..0000000 --- a/pyews/service/getitem.py +++ /dev/null @@ -1,94 +0,0 @@ -import uuid -import json -import xmltodict -from collections import OrderedDict -from ..core import Core -from pyews.utils.exceptions import ObjectType, SoapResponseHasError, SoapAccessDeniedError - - -class GetItem(Core): - - def __init__(self, userconfiguration): - super(GetItem, self).__init__(userconfiguration) - - def __camel_to_snake(self, s): - return ''.join(['_'+c.lower() if c.isupper() else c for c in s]).lstrip('_') - - def __process_keys(self, key): - return_value = key.replace('t:','') - if return_value.startswith('@'): - return_value = return_value.lstrip('@') - return self.__camel_to_snake(return_value) - - def __process_dict(self, obj): - if isinstance(obj, dict): - obj = { - self.__process_keys(key): self.__process_dict(value) for key, value in obj.items() - } - return obj - - def __process_single_calendar_item(self, value): - ordered_dict = xmltodict.parse(str(value)) - item_dict = json.loads(json.dumps(ordered_dict)) - return self.__process_dict(item_dict) - - def __parse_response(self, value): - return_dict = {} - if value.find('ResponseCode').string == 'NoError': - for item_type in ['CalendarItem', 'Contact', 'Message', 'Item']: - if value.find(item_type): - for item in getattr(value.find('Items'), item_type): - return_dict.update(self.__process_single_calendar_item(item)) - return return_dict - - def run(self, item_id, change_key=None): - return self.__parse_response(self.invoke(self.soap(item_id, change_key=change_key))) - - def soap(self, item_id, change_key=None): - '''Creates the SOAP XML message body - - Args: - email_address (str): A single email addresses you want to GetInboxRules for - - Returns: - str: Returns the SOAP XML request body - ''' - if self.userconfiguration.impersonation: - impersonation_header = self.userconfiguration.impersonation.header - else: - impersonation_header = '' - - if change_key: - item_id_string = ''.format( - item_id=item_id, - change_key=change_key - ) - else: - item_id_string = ''.format( - item_id=item_id - ) - - return ''' - - - - - - - - AllProperties - true - false - - - {item_id_string} - - - -'''.format( - version=self.userconfiguration.exchange_version, - item_id_string=item_id_string) - diff --git a/pyews/service/getsearchablemailboxes.py b/pyews/service/getsearchablemailboxes.py deleted file mode 100644 index 9670210..0000000 --- a/pyews/service/getsearchablemailboxes.py +++ /dev/null @@ -1,91 +0,0 @@ -from ..core import Core - - -class GetSearchableMailboxes(Core): - '''Identifies all searchable mailboxes based on the provided UserConfiguration object's permissions - - Example: - - To use any service class you must provide a UserConfiguration object first. - - You can acquire - - ```python - from pyews import UserConfiguration - from pyews import GetSearchableMailboxes - - userconfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123' - ) - - searchable_mailboxes = GetSearchableMailboxes(userconfig).run() - ``` - - If you want to use a property from this object with another class then you can iterate through the list of of mailbox properties. - For example, if used in conjunction with the :doc:`searchmailboxes` you first need to create a list of mailbox reference_ids. - - ```python - id_list = [] - for id in searchable_mailboxes.run(): - id_list.append(id.get('reference_id')) - searchResults = SearchMailboxes(userconfig).run('subject:"Phishing Email Subject"', id_list) - ``` - - Args: - userconfiguration (UserConfiguration): A UserConfiguration object created using the UserConfiguration class - ''' - def __init__(self, userconfiguration): - super(GetSearchableMailboxes, self).__init__(userconfiguration) - - def __parse_response(self, value): - '''Creates and sets a response object - - Args: - value (str): The raw response from a SOAP request - ''' - return_list = [] - if value.find('ResponseCode').string == 'NoError': - for item in value.find_all('SearchableMailbox'): - return_list.append({ - 'reference_id': item.ReferenceId.string, - 'primary_smtp_address': item.PrimarySmtpAddress.string, - 'display_name': item.DisplayName.string, - 'is_membership_group': item.IsMembershipGroup.string, - 'is_external_mailbox': item.IsExternalMailbox.string, - 'external_email_address': item.ExternalEmailAddress.string, - 'guid': item.Guid.string - }) - return return_list - - def run(self): - self.raw_xml = self.invoke(self.soap()) - return self.__parse_response(self.raw_xml) - - def soap(self): - '''Creates the SOAP XML message body - - Returns: - str: Returns the SOAP XML request body - ''' - if self.userconfiguration.impersonation: - impersonation_header = self.userconfiguration.impersonation.header - else: - impersonation_header = '' - - return ''' - - - - {header} - - - - true - - -'''.format( - version=self.userconfiguration.exchange_version, - header=impersonation_header) diff --git a/pyews/service/getusersettings.py b/pyews/service/getusersettings.py deleted file mode 100644 index 83b1bf4..0000000 --- a/pyews/service/getusersettings.py +++ /dev/null @@ -1,93 +0,0 @@ -import logging -from ..core import Core - -__LOGGER__ = logging.getLogger(__name__) - - -class GetUserSettings(Core): - '''Gets user settings based on the provided UserConfiguration object. - - This class is used as an alternative to Autodiscover since - GetUserSettings endpoint is a common endpoint across all versions - of Microsoft Exchange & Office 365. - - Examples: - - To use any service class you must provide a UserConfiguration object first. - Like all service classes, you can access formatted properties from the EWS endpoint using the `response` property. - - By passing in a UserConfiguration object we can - - ```python - userConfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123' - ) - print(GetUserSettings(userConfig).run()) - ``` - - Args: - userconfiguration (UserConfiguration): A UserConfiguration object created using the UserConfiguration class - ''' - - def __init__(self, userconfiguration): - super(GetUserSettings, self).__init__(userconfiguration) - - def __parse_response(self, value): - '''Creates and sets a response object - - Args: - value (str): The raw response from a SOAP request - ''' - return_dict = {} - for item in value.find_all('UserSetting'): - return_dict[self.camel_to_snake(item.Name.string)] = item.Value.string - return return_dict - - def run(self, ews_url, exchange_version): - self.raw_xml = self.invoke(self.soap(ews_url, exchange_version)) - return self.__parse_response(self.raw_xml) - - def soap(self, ews_url, exchange_version): - '''Creates the SOAP XML message body - - Returns: - str: Returns the SOAP XML request body - ''' - return ''' - - - {version} - http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/GetUserSettings - {to} - - - - - - - {mailbox} - - - - InternalEwsUrl - ExternalEwsUrl - UserDisplayName - UserDN - UserDeploymentId - InternalMailboxServer - MailboxDN - ActiveDirectoryServer - CasVersion - EwsSupportedSchemas - - - - -'''.format( - to=ews_url, - version=exchange_version, - mailbox=self.userconfiguration.credentials.email_address) diff --git a/pyews/service/operation.py b/pyews/service/operation.py new file mode 100644 index 0000000..9ac7540 --- /dev/null +++ b/pyews/service/operation.py @@ -0,0 +1,26 @@ +from .base import Base, etree, abc + + +class Operation(Base): + """Operation class inherits from the Base class + and defines namespaces, headers, and body + of EWS Operation SOAP request + """ + + def get(self, exchange_version): + self.__logger.info('Building SOAP request for {current}'.format(current=self.__class__.__name__)) + ENVELOPE = self.SOAP_MESSAGE_ELEMENT.Envelope + HEADER = self.SOAP_NAMESPACE.Header + self.envelope = ENVELOPE( + HEADER( + self.T_NAMESPACE.RequestedServerVersion(Version=exchange_version), + ), + self.BODY_ELEMENT( + self.soap() + ) + ) + return etree.tostring(self.envelope) + + @abc.abstractmethod + def soap(self): + raise NotImplementedError diff --git a/pyews/service/resolvenames.py b/pyews/service/resolvenames.py deleted file mode 100644 index 6f6466f..0000000 --- a/pyews/service/resolvenames.py +++ /dev/null @@ -1,87 +0,0 @@ -from ..core import Core -from ..utils.exchangeversion import ExchangeVersion - - -class ResolveNames(Core): - '''Resolve names based on the provided UserConfiguration object. - - This class is used as an alternative to Autodiscover since ResolveNames endpoint - is a common endpoint across all versions of Microsoft Exchange & Office 365. - - Examples: - - To use any service class you must provide a UserConfiguration object first. - Like all service classes, you can access formatted properties from the EWS endpoint using the `response` property. - - By passing in a UserConfiguration object we can - - ```python - userConfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123' - ) - print(ResolveNames(userConfig)) - ``` - - Args: - userconfiguration (UserConfiguration): A UserConfiguration object created using the UserConfiguration class - ''' - def __init__(self, userconfiguration): - super(ResolveNames, self).__init__(userconfiguration) - - def __parse_response(self, value): - '''Creates and sets a response object - - Args: - value (str): The raw response from a SOAP request - ''' - return_dict = {} - if value and value.find('ResolveNamesResponse'): - temp = value.find('ServerVersionInfo') - return_dict['server_version_info'] = temp - ver = "{major}.{minor}".format( - major=temp['MajorVersion'], - minor=temp['MinorVersion'] - ) - self.exchange_version = ExchangeVersion(ver).exchangeVersion - for item in value.find('ResolutionSet'): - if item.find('Mailbox'): - for i in item.find('Mailbox'): - return_dict[self.camel_to_snake(i.name)] = i.string - if item.find('Contact'): - for i in item.find('Contact').descendants: - if i.name == 'Entry' and i.string: - return_dict[self.camel_to_snake(i.name)] = i.string - else: - if i.name and i.string: - return_dict[self.camel_to_snake(i.name)] = i.string - return return_dict - - def run(self): - self.raw_xml = self.invoke(self.soap()) - return self.__parse_response(self.raw_xml) - - def soap(self): - '''Creates the SOAP XML message body - - Returns: - str: Returns the SOAP XML request body - ''' - return ''' - - - - - - - {email} - - - - '''.format( - version=self.userconfiguration.exchange_version, - email=self.userconfiguration.credentials.email_address) diff --git a/pyews/service/searchmailboxes.py b/pyews/service/searchmailboxes.py deleted file mode 100644 index 1dff5ad..0000000 --- a/pyews/service/searchmailboxes.py +++ /dev/null @@ -1,178 +0,0 @@ -import re -import xmltodict -import json - -from ..core import Core -from .getitem import GetItem -from pyews.utils.exchangeversion import ExchangeVersion -from pyews.utils.exceptions import ObjectType, DeleteTypeError, SoapResponseHasError, SoapAccessDeniedError, SearchScopeError - - -class SearchMailboxes(Core): - '''Search mailboxes based on a search query. - - Examples: - - To use any service class you must provide a UserConfiguration object first. - Like all service classes, you can access formatted properties from the EWS endpoint using the `response` property. - - The search query parameter takes a specific format, below are examples of different situations - as well as comments that explain that situation: - - ```python - # Searching for a keyword in a subject - # You can specify the word you are looking for as either `account` or `Account` - searchQuery = 'subject:account' - - # Searching for an exact string in a subject - searchQuery = 'subject:"Your Account is about to expire"' - - # Searching for a url in a email's body - searchQuery = 'body:"https://google.com"' - - # You can combine your search query by adding grouping logical operators - # Here is an example of searching for two subject strings and a body string match - searchQuery = 'subject:(account OR phishing) AND body:"https://google.com"' - ``` - - For more information take a look at Microsoft's documentation for their= `Advanced Query Syntax (AQS) `_ - - - By passing in a search_query, UserConfiguration object, and a mailbox_id we can search that specific mailbox or a list of mailbox referenceIds - - ```python - from pyews import UserConfiguration - from pyews import SearchMailboxes - - userConfig = UserConfiguration( - 'first.last@company.com', - 'mypassword123' - ) - - referenceId = '/o=ExchangeLabs/ou=Exchange Administrative Group (FYDIBOHF23SPDLT)/cn=Recipients/cn=5341a4228e8c433ba81b4b4b6d75e100-last.first' - searchResults = SearchMailboxes(userConfig).run('subject:account', referenceId) - ``` - - Args: - search_query (str): A EWS QueryString. More information can be found at https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/querystring-querystringtype - userconfiguration (UserConfiguration): A UserConfiguration object created using the UserConfiguration class - mailbox_id (str or list): A single or list of mailbox IDs to search. This mailbox id is a ReferenceId - search_scope (str, optional): Defaults to 'All'. The search scope for the provided mailbox ids. The options are ['All', 'PrimaryOnly', 'ArchiveOnly'] - - Raises: - ObjectType: An incorrect object type has been used - SearchScopeError: The provided search scope is not one of the following options: ['All', 'PrimaryOnly', 'ArchiveOnly'] - ''' - - def __init__(self, userconfiguration): - super(SearchMailboxes, self).__init__(userconfiguration) - self.__get_item = GetItem(userconfiguration) - - def __process_keys(self, key): - return_value = key.replace('t:','') - if return_value.startswith('@'): - return_value = return_value.lstrip('@') - return self.camel_to_snake(return_value) - - def __process_dict(self, obj): - if isinstance(obj, dict): - obj = { - self.__process_keys(key): self.__process_dict(value) for key, value in obj.items() - } - return obj - - def __process_single_change(self, value): - ordered_dict = xmltodict.parse(str(value)) - item_dict = json.loads(json.dumps(ordered_dict)) - return self.__process_dict(item_dict) - - def __parse_response(self, value): - '''Creates and sets a response object - - Args: - value (str): The raw response from a SOAP request - ''' - return_list = [] - if value.find('ResponseCode').string == 'NoError': - for item in value.find_all('SearchPreviewItem'): - new_dict = self.__process_single_change(item).pop('search_preview_item') - if new_dict.get('id'): - try: - new_dict.update(self.__get_item.run(new_dict['id'].get('id'))) - except: - pass - return_list.append(new_dict) - return return_list - - def run(self, search_query, mailbox_list, search_scope='All'): - self.search_query = search_query - if search_scope in ['All', 'PrimaryOnly', 'ArchiveOnly']: - self.search_scope = search_scope - else: - raise SearchScopeError('Please use the default SearchScope of All or specify PrimaryOnly or ArchiveOnly') - self.raw_xml = self.invoke(self.soap(mailbox_list)) - return self.__parse_response(self.raw_xml) - - def soap(self, mailbox): - '''Creates the SOAP XML message body - - Args: - mailbox (str or list): A single or list of email mailbox ReferenceIds to search - - Returns: - str: Returns the SOAP XML request body - ''' - if self.userconfiguration.impersonation: - impersonation_header = self.userconfiguration.impersonation.header - else: - impersonation_header = '' - - mailbox_search_scope = self._mailbox_search_scope(mailbox) - - return ''' - - - - {header} - - - - - - {query} - - {scope} - - - - PreviewOnly - - -'''.format( - version=self.userconfiguration.exchange_version, - header=impersonation_header, - query=self.search_query, - scope=mailbox_search_scope) - - def _mailbox_search_scope(self, mailbox): - '''Creates a MailboxSearchScope XML element from a single or list of mailbox ReferenceIds - - Returns: - str: Returns the MailboxSearchScope SOAP XML element(s) - ''' - mailbox_soap_element = '' - if isinstance(mailbox, list): - for item in mailbox: - mailbox_soap_element += ''' - {mailbox} - {scope} - '''.format(mailbox=item, scope=self.search_scope) - else: - mailbox_soap_element = ''' - {mailbox} - {scope} - '''.format(mailbox=mailbox, scope=self.search_scope) - - return mailbox_soap_element diff --git a/pyews/utils/attributes.py b/pyews/utils/attributes.py new file mode 100644 index 0000000..69a70f0 --- /dev/null +++ b/pyews/utils/attributes.py @@ -0,0 +1,183 @@ +FOLDER_LIST = [ + 'msgfolderroot', + 'calendar', + 'contacts', + 'deleteditems', + 'drafts', + 'inbox', + 'journal', + 'notes', + 'outbox', + 'sentitems', + 'tasks', + 'junkemail', + 'searchfolders', + 'voicemail', + 'recoverableitemsdeletions', + 'recoverableitemsversions', + 'recoverableitemspurges', + 'recipientcache', + 'quickcontacts', + 'conversationhistory', + 'todosearch', + 'mycontacts', + 'imcontactlist', + 'peopleconnect', + 'favorites' + ] + +MESSAGE_ELEMENTS = { + "MimeContent": None, + "ItemClass": [ + 'AcceptItem', + 'CalendarItem', + 'Contact', + 'Conversation', + 'DeclineItem', + 'DistributionList', + 'GlobalItemClass', + 'Item', + 'MeetingCancellation', + 'MeetingMessage', + 'MettingRequest', + 'MeetingResponse', + 'Message', + 'RemoveItem', + 'Task', + 'TentativelyAcceptItem' + ], + "Subject": None, + "Sensitivity": [ + 'Normal', + 'Personal', + 'Private', + 'Confidential' + ], + "Body": { + 'BodyType': [ + 'HTML', + 'Text' + ], + 'IsTruncated': [ + 'true', + 'false' + ] + }, + "Attachments": { + 'FileAttachment':{ + 'Name': None, + 'IsInline': ['true', 'false'], + 'Content': None + } + }, + "Importance": [ + 'Low', + 'Normal', + 'High' + ], + "IsSubmitted": [ + 'true', + 'false' + ], + "IsDraft": [ + 'true', + 'false' + ], + "DisplayCc": None, + "DisplayTo": None, + "Sender": { + 'Mailbox': { + 'Name': None, + 'EmailAddress': None, + 'MailboxType': [ + 'Mailbox', + 'PublicDL', + 'PrivateDl', + 'Contact', + 'PublicFolder', + 'Unknown', + 'OneOff', + 'GroupMailbox' + ] + } + }, + "ToRecipients": { + 'Mailbox': { + 'Name': None, + 'EmailAddress': None, + 'MailboxType': [ + 'Mailbox', + 'PublicDL', + 'PrivateDl', + 'Contact', + 'PublicFolder', + 'Unknown', + 'OneOff', + 'GroupMailbox' + ] + } + }, + "CcRecipients": { + 'Mailbox': { + 'Name': None, + 'EmailAddress': None, + 'MailboxType': [ + 'Mailbox', + 'PublicDL', + 'PrivateDl', + 'Contact', + 'PublicFolder', + 'Unknown', + 'OneOff', + 'GroupMailbox' + ] + } + }, + "BccRecipients": { + 'Mailbox': { + 'Name': None, + 'EmailAddress': None, + 'MailboxType': [ + 'Mailbox', + 'PublicDL', + 'PrivateDl', + 'Contact', + 'PublicFolder', + 'Unknown', + 'OneOff', + 'GroupMailbox' + ] + } + }, + "IsReadReceiptRequested": [ + 'true', + 'false' + ], + "IsDeliveryReceiptRequested": [ + 'true', + 'false' + ], + "From": { + 'Mailbox': { + 'Name': None, + 'EmailAddress': None, + 'MailboxType': [ + 'Mailbox', + 'PublicDL', + 'PrivateDl', + 'Contact', + 'PublicFolder', + 'Unknown', + 'OneOff', + 'GroupMailbox' + ] + } + }, + "IsRead": [ + 'true', + 'false' + ], + "IsResponseRequested": [ + 'true' + ] +} \ No newline at end of file diff --git a/pyews/utils/exceptions.py b/pyews/utils/exceptions.py index a680f9a..ace9fd9 100644 --- a/pyews/utils/exceptions.py +++ b/pyews/utils/exceptions.py @@ -1,47 +1,13 @@ -class IncorrectParameters(Exception): - '''Raised when the incorrect configuration of parameters is passed into a Class''' - pass - -class SoapConnectionRefused(Exception): - '''Raised when a connection is refused from the server''' - pass - -class SoapConnectionError(Exception): - '''Raised when an error occurs attempting to connect to Exchange Web Services endpoint''' - pass - -class SoapResponseIsNoneError(Exception): - '''Raised when a SOAP request response is None''' - pass - -class SoapResponseHasError(Exception): - '''Raised when a SOAP request response contains an error''' - pass - -class SoapAccessDeniedError(Exception): - '''Raised when a SOAP response message says Access Denied''' - pass - -class ExchangeVersionError(Exception): - '''Raised when using an Exchange Version that is not supported''' - pass - -class ObjectType(Exception): - '''Raised when the object type used is not the correct type''' - pass - -class DeleteTypeError(Exception): - '''Incorrect DeleteType is used''' - pass - -class SearchScopeError(Exception): - '''Incorrect SearchScope is used''' - pass - -class CredentialsError(Exception): - '''Unable to create a credential object with the provided input''' - pass - -class UserConfigurationError(Exception): - '''Unable to create or set a UserConfiguration Configuration property''' - pass \ No newline at end of file +class UknownValueError(ValueError): + """Raised when the provided value is unkown or is not + in a specified list or dictionary map + """ + def __init__(self, provided_value=None, known_values=None): + if provided_value and known_values: + if isinstance(known_values, list): + super().__init__("The provided value {} is unknown. Please provide one of the following values: '{}'".format( + provided_value, + ','.join([x for x in known_values]) + )) + else: + pass \ No newline at end of file diff --git a/pyews/utils/exchangeversion.py b/pyews/utils/exchangeversion.py deleted file mode 100644 index ed74b5d..0000000 --- a/pyews/utils/exchangeversion.py +++ /dev/null @@ -1,92 +0,0 @@ - -class ExchangeVersion(object): - '''Used to validate compatible Exchange Versions across multiple service endpoints - - Examples: - - To determine if a version number is a valid ExchangeVersion then would pass in the value when instantiating this object: - - ```python - version = ExchangeVersion('15.20.5').exchangeVersion - print(version) - ``` - - ```text - # output - Exchange2016 - ``` - - To verify an ExchangeVersion is supported, you can view the supported version by access the EXCHANGE_VERSIONS attribute - - ```python - versions = ExchangeVersion('15.20.5').EXCHANGE_VERSIONS - print(versions) - ``` - - ```text - ['Exchange2019', 'Exchange2016', 'Exchange2013_SP1', 'Exchange2013', 'Exchange2010_SP2', 'Exchange2010_SP1', 'Exchange2010'] - ``` - - Args: - version (str): An Exchange Version number. Example: 15.20.5 = Exchange2016 - ''' - - # Borrowed from exchangelib: https://github.com/ecederstrand/exchangelib/blob/master/exchangelib/version.py#L54 - # List of build numbers here: https://docs.microsoft.com/en-us/Exchange/new-features/build-numbers-and-release-dates?view=exchserver-2019 - API_VERSION_MAP = { - 8: { - 0: 'Exchange2007', - 1: 'Exchange2007_SP1', - 2: 'Exchange2007_SP1', - 3: 'Exchange2007_SP1', - }, - 14: { - 0: 'Exchange2010', - 1: 'Exchange2010_SP1', - 2: 'Exchange2010_SP2', - 3: 'Exchange2010_SP2', - }, - 15: { - 0: 'Exchange2013', # Minor builds starting from 847 are Exchange2013_SP1, see api_version() - 1: 'Exchange2016', - 2: 'Exchange2019', - 20: 'Exchange2016', # This is Office365. See issue #221 - } - } - - EXCHANGE_VERSIONS = ['Exchange2019', 'Exchange2016', 'Exchange2013_SP1', 'Exchange2013', 'Exchange2010_SP2', 'Exchange2010_SP1', 'Exchange2010'] - - def __init__(self, version): - self.exchangeVersion = self._get_api_version(version) - - def _get_api_version(self, version): - '''Gets a string representation of an Exchange Version number - - Args: - version (str): An Exchange Version number. Example: 15.20.5 - - Returns: - str: A string representation of a Exchange Version number. Example: Exchange2016 - ''' - - if version == '15.0.847.32': - return 'Exchange2013_SP1' - else: - ver = version.split('.') - return self.API_VERSION_MAP[int(ver[0])][int(ver[1])] - - @staticmethod - def valid_version(version): - '''Determines if a string version name is in list of accepted Exchange Versions - - Args: - version (str): String used to determine if it is an acceptable Exchange Version - - Returns: - bool: Returns either True or False if the passed in version is an acceptable Exchange Version - ''' - if version == 'Office365': - return True - elif version in ExchangeVersion.EXCHANGE_VERSIONS: - return True - return False diff --git a/pyews/utils/logger.py b/pyews/utils/logger.py index 1e10786..26f6ac5 100644 --- a/pyews/utils/logger.py +++ b/pyews/utils/logger.py @@ -2,21 +2,43 @@ import logging.config import yaml -def setup_logging( - default_path='../logging.yaml', - default_level=logging.INFO, - env_key='LOG_CFG' -): - """Setup logging configuration - - """ - path = default_path - value = os.getenv(env_key, None) - if value: - path = value - if os.path.exists(path): - with open(path, 'rt') as f: - config = yaml.safe_load(f.read()) - logging.config.dictConfig(config) - else: - logging.basicConfig(level=default_level) \ No newline at end of file +from logging import FileHandler, DEBUG, INFO, ERROR, WARNING, CRITICAL +import logging + + +class DebugFileHandler(FileHandler): + def __init__(self, filename, mode='a', encoding=None, delay=False): + super().__init__(filename, mode, encoding, delay) + + def emit(self, record): + if not record.levelno == DEBUG: + return + super().emit(record) + + +class LoggingBase(type): + def __init__(cls, *args): + super().__init__(*args) + cls.setup_logging() + + # Explicit name mangling + logger_attribute_name = '_' + cls.__name__ + '__logger' + + # Logger name derived accounting for inheritance for the bonus marks + logger_name = '.'.join([c.__name__ for c in cls.mro()[-2::-1]]) + + setattr(cls, logger_attribute_name, logging.getLogger(logger_name)) + + def setup_logging(cls, default_path='./data/logging.yml', default_level=logging.INFO, env_key='LOG_CFG'): + """Setup logging configuration + """ + path = os.path.abspath(os.path.expanduser(os.path.expandvars(default_path))) + value = os.getenv(env_key, None) + if value: + path = value + if os.path.exists(os.path.abspath(path)): + with open(path, 'rt') as f: + config = yaml.safe_load(f.read()) + logger = logging.config.dictConfig(config) + else: + logger = logging.basicConfig(level=default_level) diff --git a/requirements.txt b/requirements.txt index ad7df9f..b0394eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -requests -beautifulsoup4 -lxml -pyyaml +requests==2.25.1 +beautifulsoup4==4.9.3 +lxml==4.5.1 +pyyaml==5.4.1 xmltodict==0.12.0 -m2r==0.2.1 \ No newline at end of file +m2r==0.2.1 +fire==0.3.1 \ No newline at end of file From 9fb8dff84a53c2690973389e624184046557970d Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Wed, 5 May 2021 13:56:06 -0500 Subject: [PATCH 16/72] Updated documentation --- README.md | 43 +++++++++++-------- docs/core/authentication.md | 11 +++++ docs/core/core.md | 9 ++++ docs/core/endpoints.md | 9 ++++ docs/{utils => core}/exchangeversion.md | 2 +- docs/core/root.md | 12 ++++++ docs/endpoint/root.md | 20 --------- docs/index.md | 43 +++++++++++-------- .../add_additional_service_endpoints.md | 3 ++ docs/utils/root.md | 1 - 10 files changed, 96 insertions(+), 57 deletions(-) create mode 100644 docs/core/authentication.md create mode 100644 docs/core/core.md create mode 100644 docs/core/endpoints.md rename docs/{utils => core}/exchangeversion.md (73%) create mode 100644 docs/core/root.md diff --git a/README.md b/README.md index 14ca99a..efaaed7 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ pip install py-ews ## Creating EWS Object +For convience, `py-ews` offers a simple interface to access all available EWS `endpoints` in the form of methods. Each of these methods have +their own required inputs based on the individual endpoint. No matter which endpoint you use, you must first instantiate the `EWS` class by providing +authentication details. + When instantiating the `EWS` class you will need to provide credentials which will be used for all methods within the EWS class. ```python @@ -97,6 +101,19 @@ ews = EWS( If you would like to specify a specific version of Exchange to use, you can provide that using the `exchange_version` parameter. By default `pyews` will attempt all Exchange versions as well as multiple static and generated EWS URLs. +Finally, if you would like to `impersonate_as` a specific user you must provide their primary SMTP address when instantiating the `EWS` class object: + + +```python +from pyews import EWS + +ews = EWS( + 'myaccount@company.com', + 'Password1234', + impersonate_as='myotheraccount@company.com' +) +``` + ## Using Provided Methods Once you have instantiated the EWS class with your credentials, you will have access to pre-exposed methods for each endpoint. These methods are: @@ -114,19 +131,18 @@ Once you have instantiated the EWS class with your credentials, you will have ac * sync_folder_items * create_item -## Importing Endpoints - -If you would like to write your own methods, you can import each endpoint directly into your script. +## Access Classes Directly -This example will demonstrate how you can identify which mailboxes you have access to by using the [GetSearchableMailboxes](docs/endpoint/getsearchablemailboxes.md) EWS endpoint. +In some cases you may want to skip using the `EWS` interface class and build your own wrapper around `py-ews`. To do this, you must first import the `Authentication` class and provide +credential and other details before invoking a desired `endpoint`. Below is an example of this: ```python -from pyews import Core -from pyews.endpoint import GetSearchableMailboxes +from pyews import Authentication, GetSearchableMailboxes -Core.exchange_versions = 'Exchange2016' -Core.credentials = ('mymailbox@mailbox.com', 'some password') -Core.endpoints = 'mailbox.com' +Authentication( + 'myaccount@company.com', + 'Password1234' +) reference_id_list = [] for mailbox in GetSearchableMailboxes().run(): @@ -134,14 +150,7 @@ for mailbox in GetSearchableMailboxes().run(): print(mailbox) ``` -Once you have identified a list of mailbox reference ids, then you can begin searching all of those mailboxes by using the [SearchMailboxes](docs/endpoint/searchmailboxes.md) EWS endpoint. - -```python -from pyews.endpoint import SearchMailboxes - -for search_item in SearchMailboxes('phish', reference_id_list).run(): - print(search_item) -``` +As you can see, you must instantiate the `Authentication` class first before calling an endpoint. By the way, you can import all `endpoints` directly without using the `EWS` interface. **For more examples and usage, please refer to the individual class documentation** diff --git a/docs/core/authentication.md b/docs/core/authentication.md new file mode 100644 index 0000000..7d0a296 --- /dev/null +++ b/docs/core/authentication.md @@ -0,0 +1,11 @@ +# Authentication + +The Authentication class is used to configure all communications with Exchange. Additionally, this class contains settings which are used when generating and making SOAP requests. + +This class defines the authenication credentials, endpoints to attempt, exchange versions, impersonation details, and more. + +```eval_rst +.. autoclass:: pyews.core.authentication.Authentication + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/core/core.md b/docs/core/core.md new file mode 100644 index 0000000..a7bda7c --- /dev/null +++ b/docs/core/core.md @@ -0,0 +1,9 @@ +# Core + +The Core class is inherited by all other classes within `py-ews`. This class controls logging as well as parsing of EWS SOAP request responses. + +```eval_rst +.. autoclass:: pyews.core.core.Core + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/core/endpoints.md b/docs/core/endpoints.md new file mode 100644 index 0000000..6d83f41 --- /dev/null +++ b/docs/core/endpoints.md @@ -0,0 +1,9 @@ +# Endpoints + +This documentation provides details about the set and generated API endpoints used within the `pyews` package. + +```eval_rst +.. autoclass:: pyews.core.endpoints.Endpoints + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/docs/utils/exchangeversion.md b/docs/core/exchangeversion.md similarity index 73% rename from docs/utils/exchangeversion.md rename to docs/core/exchangeversion.md index 534d0e0..d67db77 100644 --- a/docs/utils/exchangeversion.md +++ b/docs/core/exchangeversion.md @@ -3,7 +3,7 @@ This documentation provides details about the ExchangeVersion class within the `pyews` package. ```eval_rst -.. automodule:: pyews.exchangeversion.ExchangeVersion +.. autoclass:: pyews.core.exchangeversion.ExchangeVersion :members: :undoc-members: ``` \ No newline at end of file diff --git a/docs/core/root.md b/docs/core/root.md new file mode 100644 index 0000000..0d5a91c --- /dev/null +++ b/docs/core/root.md @@ -0,0 +1,12 @@ +# Core + + +```eval_rst +.. toctree:: + :maxdepth: 2 + + core + authentication + exchangeversion + endpoints +``` \ No newline at end of file diff --git a/docs/endpoint/root.md b/docs/endpoint/root.md index 1014736..f070d06 100644 --- a/docs/endpoint/root.md +++ b/docs/endpoint/root.md @@ -4,26 +4,6 @@ This documentation provides details about the available Exchange Web Services en All endpoints inherit from either the Autodiscover or Operation classes. These classes make extensibility much easier and allows users of this package to define new endpoints easily. - -* [AddDelegate](adddelegate.md) -* [ConvertId](convertid.md) -* [CreateItem](createitem.md) -* [DeleteItem](deleteitem.md) -* [ExecuteSearch](executesearch.md) -* [ExpandDL](expanddl.md) -* [GetAttachment](getattachment.md) -* [GetHiddenInboxRules](gethiddeninboxrules.md) -* [GetInboxRules](getinboxrules.md) -* [GetItem](getitem.md) -* [GetSearchableMailboxes](getsearchablemailboxes.md) -* [GetServiceConfiguration](getserviceconfiguration.md) -* [GetUserSettings](getusersettings.md) -* [ResolveNames](resolvenames.md) -* [SearchMailboxes](searchmailboxes.md) -* [SyncFolderHierarchy](syncfolderhierarchy.md) -* [SyncFolderItems](syncfolderitems.md) - - ```eval_rst .. toctree:: :maxdepth: 2 diff --git a/docs/index.md b/docs/index.md index 038a85f..bbee8bd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -97,6 +97,20 @@ ews = EWS( If you would like to specify a specific version of Exchange to use, you can provide that using the `exchange_version` parameter. By default `pyews` will attempt all Exchange versions as well as multiple static and generated EWS URLs. + +Finally, if you would like to `impersonate_as` a specific user you must provide their primary SMTP address when instantiating the `EWS` class object: + + +```python +from pyews import EWS + +ews = EWS( + 'myaccount@company.com', + 'Password1234', + impersonate_as='myotheraccount@company.com' +) +``` + ## Using Provided Methods Once you have instantiated the EWS class with your credentials, you will have access to pre-exposed methods for each endpoint. These methods are: @@ -114,19 +128,18 @@ Once you have instantiated the EWS class with your credentials, you will have ac * sync_folder_items * create_item -## Importing Endpoints - -If you would like to write your own methods, you can import each endpoint directly into your script. +## Access Classes Directly -This example will demonstrate how you can identify which mailboxes you have access to by using the [GetSearchableMailboxes](endpoint/getsearchablemailboxes.md) EWS endpoint. +In some cases you may want to skip using the `EWS` interface class and build your own wrapper around `py-ews`. To do this, you must first import the `Authentication` class and provide +credential and other details before invoking a desired `endpoint`. Below is an example of this: ```python -from pyews import Core -from pyews.endpoint import GetSearchableMailboxes +from pyews import Authentication, GetSearchableMailboxes -Core.exchange_versions = 'Exchange2016' -Core.credentials = ('mymailbox@mailbox.com', 'some password') -Core.endpoints = 'mailbox.com' +Authentication( + 'myaccount@company.com', + 'Password1234' +) reference_id_list = [] for mailbox in GetSearchableMailboxes().run(): @@ -134,18 +147,11 @@ for mailbox in GetSearchableMailboxes().run(): print(mailbox) ``` -Once you have identified a list of mailbox reference ids, then you can begin searching all of those mailboxes by using the [SearchMailboxes](endpoint/searchmailboxes.md) EWS endpoint. - -```python -from pyews.endpoint import SearchMailboxes - -for search_item in SearchMailboxes('phish', reference_id_list).run(): - print(search_item) -``` +As you can see, you must instantiate the `Authentication` class first before calling an endpoint. By the way, you can import all `endpoints` directly without using the `EWS` interface. **For more examples and usage, please refer to the individual class documentation** -* [Endpoint](service/root.md) +* [Endpoint](endpoint/root.md) ## Release History @@ -176,6 +182,7 @@ Distributed under the MIT license. See ``LICENSE`` for more information. :maxdepth: 2 :caption: Contents: + core/root endpoint/root service/root utils/root diff --git a/docs/service/add_additional_service_endpoints.md b/docs/service/add_additional_service_endpoints.md index 0772b41..22e94f1 100644 --- a/docs/service/add_additional_service_endpoints.md +++ b/docs/service/add_additional_service_endpoints.md @@ -99,8 +99,11 @@ self.T_NAMESPACE.AlternateId(Format="EwsId", Id="AAMkAGZhN2IxYTA0LWNiNzItN=", Ma Now that we have our newly defined endpoint we can instantiate it and then just call the `run` method. ```python +from pyews import Authentication from getappmanifests import GetAppManifests +Authentication('username', 'password') + print(GetAppManifests().run()) ``` diff --git a/docs/utils/root.md b/docs/utils/root.md index df5e58c..b888475 100644 --- a/docs/utils/root.md +++ b/docs/utils/root.md @@ -6,6 +6,5 @@ :maxdepth: 2 exceptions - exchangeversion logger ``` \ No newline at end of file From a334d0c869134e5c5164085df00c68da824c0b63 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Wed, 5 May 2021 13:57:19 -0500 Subject: [PATCH 17/72] Added authentication classes and moved core to core subpackage --- pyews/__init__.py | 4 +- pyews/core/__init__.py | 4 ++ pyews/core/authentication.py | 86 +++++++++++++++++++++++++++++ pyews/{ => core}/core.py | 55 +----------------- pyews/{ => core}/endpoints.py | 0 pyews/{ => core}/exchangeversion.py | 0 pyews/ews.py | 21 +------ pyews/service/base.py | 22 ++++++-- pyews/service/operation.py | 3 +- 9 files changed, 117 insertions(+), 78 deletions(-) create mode 100644 pyews/core/__init__.py create mode 100644 pyews/core/authentication.py rename pyews/{ => core}/core.py (66%) rename pyews/{ => core}/endpoints.py (100%) rename pyews/{ => core}/exchangeversion.py (100%) diff --git a/pyews/__init__.py b/pyews/__init__.py index add7042..cfb33aa 100644 --- a/pyews/__init__.py +++ b/pyews/__init__.py @@ -1 +1,3 @@ -from .ews import EWS \ No newline at end of file +from .ews import EWS +from .core import Core, ExchangeVersion, Authentication, Endpoints +from .endpoint import * diff --git a/pyews/core/__init__.py b/pyews/core/__init__.py new file mode 100644 index 0000000..f975bd5 --- /dev/null +++ b/pyews/core/__init__.py @@ -0,0 +1,4 @@ +from .authentication import Authentication +from .core import Core +from .endpoints import Endpoints +from .exchangeversion import ExchangeVersion \ No newline at end of file diff --git a/pyews/core/authentication.py b/pyews/core/authentication.py new file mode 100644 index 0000000..4bb3724 --- /dev/null +++ b/pyews/core/authentication.py @@ -0,0 +1,86 @@ +from .exchangeversion import ExchangeVersion +from .core import Core + + +class classproperty(property): + def __get__(self, obj, objtype=None): + return super(classproperty, self).__get__(objtype) + def __set__(self, obj, value): + super(classproperty, self).__set__(type(obj), value) + def __delete__(self, obj): + super(classproperty, self).__delete__(type(obj)) + + +class Authentication(Core): + + def __init__(cls, username, password, ews_url=None, exchange_version=ExchangeVersion.EXCHANGE_VERSIONS, impersonate_as=None): + cls.impersonate_as = impersonate_as + cls.exchange_versions = exchange_version + cls.credentials = (username, password) + cls.domain = username + cls.endpoints = ews_url + + @classproperty + def impersonate_as(cls): + return cls.__impersonate_as + + @impersonate_as.setter + def impersonate_as(cls, value): + if not value: + cls.__impersonate_as = '' + else: + cls.__impersonate_as = value + + @classproperty + def credentials(cls): + return cls._credentials + + @credentials.setter + def credentials(cls, value): + if isinstance(value, tuple): + cls.domain = value[0] + cls._credentials = value + else: + raise AttributeError('Please provide both a username and password') + + @classproperty + def exchange_versions(cls): + return cls._exchange_versions + + @exchange_versions.setter + def exchange_versions(cls, value): + from .exchangeversion import ExchangeVersion + if not value: + cls._exchange_versions = ExchangeVersion.EXCHANGE_VERSIONS + elif not isinstance(value, list): + cls._exchange_versions = [value] + else: + cls._exchange_versions = value + + @classproperty + def endpoints(cls): + return cls._endpoints + + @endpoints.setter + def endpoints(cls, value): + from .endpoints import Endpoints + if not value: + cls._endpoints = Endpoints(cls.domain).get() + elif not isinstance(value, list): + cls._endpoints = [value] + else: + cls._endpoints = value + + @classproperty + def domain(cls): + return cls._domain + + @domain.setter + def domain(cls, value): + '''Splits the domain from an email address + + Returns: + str: Returns the split domain from an email address + ''' + local, _, domain = value.partition('@') + cls._domain = domain diff --git a/pyews/core.py b/pyews/core/core.py similarity index 66% rename from pyews/core.py rename to pyews/core/core.py index 42da45c..02fc65b 100644 --- a/pyews/core.py +++ b/pyews/core/core.py @@ -1,7 +1,7 @@ import xmltodict import json -from .utils.logger import LoggingBase +from ..utils.logger import LoggingBase class Core(metaclass=LoggingBase): @@ -9,59 +9,6 @@ class Core(metaclass=LoggingBase): required authentication details as well as parsing of results """ - @property - def credentials(cls): - return cls._credentials - - @credentials.setter - def credentials(cls, value): - if isinstance(value, tuple): - cls.domain = value[0] - cls._credentials = value - raise AttributeError('Please provide both a username and password') - - @property - def exchange_versions(cls): - return cls._exchange_versions - - @exchange_versions.setter - def exchange_versions(cls, value): - from .exchangeversion import ExchangeVersion - if not value: - cls._exchange_versions = ExchangeVersion.EXCHANGE_VERSIONS - elif not isinstance(value, list): - cls._exchange_versions = [value] - else: - cls._exchange_versions = value - - @property - def endpoints(cls): - return cls._endpoints - - @endpoints.setter - def endpoints(cls, value): - from .endpoints import Endpoints - if not value: - cls._endpoints = Endpoints(cls.domain).get() - elif not isinstance(value, list): - cls._endpoints = [value] - else: - cls._endpoints = value - - @property - def domain(cls): - return cls._domain - - @domain.setter - def domain(cls, value): - '''Splits the domain from an email address - - Returns: - str: Returns the split domain from an email address - ''' - local, _, domain = value.partition('@') - cls._domain = domain - def camel_to_snake(self, s): if s != 'UserDN': return ''.join(['_'+c.lower() if c.isupper() else c for c in s]).lstrip('_') diff --git a/pyews/endpoints.py b/pyews/core/endpoints.py similarity index 100% rename from pyews/endpoints.py rename to pyews/core/endpoints.py diff --git a/pyews/exchangeversion.py b/pyews/core/exchangeversion.py similarity index 100% rename from pyews/exchangeversion.py rename to pyews/core/exchangeversion.py diff --git a/pyews/ews.py b/pyews/ews.py index 3f2db04..2a2c103 100644 --- a/pyews/ews.py +++ b/pyews/ews.py @@ -1,26 +1,11 @@ -from .core import Core +from .core import Authentication from .endpoint import GetSearchableMailboxes, GetUserSettings, ResolveNames, SearchMailboxes, ExecuteSearch, GetInboxRules, GetItem, ConvertId, GetHiddenInboxRules, CreateItem, GetServiceConfiguration, SyncFolderHierarchy, SyncFolderItems, GetAttachment -from .exchangeversion import ExchangeVersion -from .endpoints import Endpoints class EWS: - _credentials = None - _ews_url = None - _exchange_version = None - - def __init__(self, username, password, ews_url=None, exchange_version=ExchangeVersion.EXCHANGE_VERSIONS): - Core.exchange_versions = exchange_version - Core.credentials = (username, password) - Core.domain = username - if not ews_url: - local, _, domain = username.partition('@') - Core.endpoints = Endpoints(domain).get() - elif not isinstance(ews_url, list): - Core.endpoints = [ews_url] - else: - Core.endpoints = ews_url + def __init__(self, username, password, ews_url=None, exchange_version=None, impersonate_as=None): + Authentication(username, password, ews_url=ews_url, exchange_version=exchange_version, impersonate_as=impersonate_as) def get_service_configuration(self, configuration_name=None, acting_as=None): return GetServiceConfiguration(configuration_name=configuration_name, acting_as=acting_as).run() diff --git a/pyews/service/base.py b/pyews/service/base.py index 1e593e3..f56c871 100644 --- a/pyews/service/base.py +++ b/pyews/service/base.py @@ -4,7 +4,7 @@ from lxml import etree from bs4 import BeautifulSoup -from ..core import Core +from ..core import Core, Authentication class Base(Core): @@ -64,6 +64,15 @@ def raw_xml(self, value): def get(self): raise NotImplementedError + def _impersonation_header(self): + if self.impersonate_as: + return self.T_NAMESPACE.ExchangeImpersonation( + self.T_NAMESPACE.ConnectingSID( + self.T_NAMESPACE.PrimarySmtpAddress(self.impersonate_as) + ) + ) + return '' + def __parse_convert_id_error_message(self, error_message): result = error_message.split('Please use the ConvertId method to convert the Id from ')[1].split(' format.')[0] return result.split(' to ') @@ -84,8 +93,13 @@ def run(self): Returns: BeautifulSoup: Returns a BeautifulSoup object or None. """ - for endpoint in Core.endpoints: - for version in Core.exchange_versions: + for item in Authentication.__dict__.keys(): + if not item.startswith('_'): + if hasattr(Authentication, item): + setattr(self, item, getattr(Authentication, item)) + + for endpoint in self.endpoints: + for version in self.exchange_versions: if self.__class__.__base__.__name__ == 'Operation' and 'autodiscover' in endpoint: self.__logger.debug('{} == Operation so skipping endpoint {}'.format(self.__class__.__base__.__name__, endpoint)) continue @@ -98,7 +112,7 @@ def run(self): url=endpoint, data=self.get(version).decode("utf-8"), headers=self.SOAP_REQUEST_HEADER, - auth=Core.credentials, + auth=self.credentials, verify=True ) diff --git a/pyews/service/operation.py b/pyews/service/operation.py index 9ac7540..49067c8 100644 --- a/pyews/service/operation.py +++ b/pyews/service/operation.py @@ -10,10 +10,11 @@ class Operation(Base): def get(self, exchange_version): self.__logger.info('Building SOAP request for {current}'.format(current=self.__class__.__name__)) ENVELOPE = self.SOAP_MESSAGE_ELEMENT.Envelope - HEADER = self.SOAP_NAMESPACE.Header + HEADER = self.SOAP_NAMESPACE.Header self.envelope = ENVELOPE( HEADER( self.T_NAMESPACE.RequestedServerVersion(Version=exchange_version), + self._impersonation_header() ), self.BODY_ELEMENT( self.soap() From f5b6193aeba811e5e87fb99fdbed68e18a202445 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Wed, 5 May 2021 14:01:05 -0500 Subject: [PATCH 18/72] modified setup to py-ews-dev --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 32bdce5..0b353dd 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ def parse_requirements(requirement_file): return f.readlines() setup( - name='py-ews', + name='py-ews-dev', version='3.0.0', packages=find_packages(exclude=['tests*']), license='MIT', From 4e375319a04bd5f0e506a12acf9e0ecedc2a2326 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 15:56:12 -0500 Subject: [PATCH 19/72] Bumped version to 3.0.1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0b353dd..337c25d 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ def parse_requirements(requirement_file): setup( name='py-ews-dev', - version='3.0.0', + version='3.0.1', packages=find_packages(exclude=['tests*']), license='MIT', description='A Python package to interact with both on-premises and Office 365 Exchange Web Services', From 39effd8295c0981fb1eb181d7557ebc11965fbf3 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 15:56:44 -0500 Subject: [PATCH 20/72] Exporting Autodiscover and Operation classes for inheritance and extensibility --- pyews/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyews/__init__.py b/pyews/__init__.py index cfb33aa..7f574a6 100644 --- a/pyews/__init__.py +++ b/pyews/__init__.py @@ -1,3 +1,4 @@ from .ews import EWS from .core import Core, ExchangeVersion, Authentication, Endpoints from .endpoint import * +from .service import Autodiscover, Operation From 1f718901f8a778f09e708be43ff2f5acb184f8a0 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 15:57:37 -0500 Subject: [PATCH 21/72] Added multi-threading and additional endpoints to ews interface --- pyews/ews.py | 69 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/pyews/ews.py b/pyews/ews.py index 2a2c103..0380dc7 100644 --- a/pyews/ews.py +++ b/pyews/ews.py @@ -1,11 +1,21 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed + from .core import Authentication -from .endpoint import GetSearchableMailboxes, GetUserSettings, ResolveNames, SearchMailboxes, ExecuteSearch, GetInboxRules, GetItem, ConvertId, GetHiddenInboxRules, CreateItem, GetServiceConfiguration, SyncFolderHierarchy, SyncFolderItems, GetAttachment +from .endpoint import GetSearchableMailboxes, GetUserSettings, ResolveNames, SearchMailboxes, ExecuteSearch, GetInboxRules, GetItem, ConvertId, GetHiddenInboxRules, CreateItem, GetServiceConfiguration, SyncFolderHierarchy, SyncFolderItems, GetAttachment, DeleteItem class EWS: - def __init__(self, username, password, ews_url=None, exchange_version=None, impersonate_as=None): - Authentication(username, password, ews_url=ews_url, exchange_version=exchange_version, impersonate_as=impersonate_as) + def __init__(self, username, password, endpoints=None, exchange_version=None, impersonate_as=None, multi_threading=False): + Authentication.credentials = (username, password) + Authentication.endpoints = endpoints + Authentication.exchange_versions = exchange_version + Authentication.impersonate_as = impersonate_as + self.multi_threading = multi_threading + + def chunk(self, items, n): + n = max(1, n) + return (items[i:i+n] for i in range(0, len(items), n)) def get_service_configuration(self, configuration_name=None, acting_as=None): return GetServiceConfiguration(configuration_name=configuration_name, acting_as=acting_as).run() @@ -19,17 +29,33 @@ def get_user_settings(self, user=None): def resolve_names(self, user=None): return ResolveNames(user=user).run() - def execute_ews_search(self, query, reference_id, search_scope='All'): - response = SearchMailboxes(query, reference_id=reference_id, search_scope=search_scope).run() + def __execute_multithreaded_search(self, query, reference_id, scope): + return SearchMailboxes(query=query, reference_id=reference_id, search_scope=scope).run() + + def execute_ews_search(self, query, reference_id, search_scope='All', thread_count=10): + response = [] return_list = [] + if self.multi_threading: + threads = [] + chunks = self.chunk(reference_id, int(len(reference_id) / thread_count)) + with ThreadPoolExecutor(max_workers=thread_count) as executor: + for chunk in chunks: + threads.append(executor.submit(self.__execute_multithreaded_search, query, chunk, search_scope)) + for task in as_completed(threads): + result = task.result() + if isinstance(result, list): + for item in result: + response.append(item) + else: + response = SearchMailboxes(query, reference_id=reference_id, search_scope=search_scope).run() for item in response: return_dict = item get_item_response = self.get_item(return_dict['id'].get('id')) if get_item_response: - for response in get_item_response: - if response.get('message').get('attachments').get('file_attachment').get('attachment_id').get('id'): + for item_response in get_item_response: + if item_response.get('message').get('attachments') and item_response.get('message').get('attachments').get('file_attachment').get('attachment_id').get('id'): attachment_details_list = [] - attachment = self.get_attachment(response.get('message').get('attachments').get('file_attachment').get('attachment_id').get('id')) + attachment = self.get_attachment(item_response.get('message').get('attachments').get('file_attachment').get('attachment_id').get('id')) for attach in attachment: attachment_dict = {} for key,val in attach.items(): @@ -37,9 +63,9 @@ def execute_ews_search(self, query, reference_id, search_scope='All'): attachment_dict[k] = v if attachment_dict: attachment_details_list.append(attachment_dict) - return_dict.update(response.pop('message')) - if attachment_details_list: - return_dict.update({'attachment_details': attachment_details_list}) + if attachment_details_list: + return_dict.update({'attachment_details': attachment_details_list}) + return_dict.update(item_response.pop('message')) return_list.append(return_dict) return return_list @@ -60,7 +86,7 @@ def get_item(self, item_id, change_key=None): response = GetItem(item_id, change_key=change_key).run() if isinstance(response, list): if any(item in response for item in ConvertId.ID_FORMATS): - convert_id_response = ConvertId(Core.credentials[0], item_id, id_type=response[0], convert_to=response[1]).run() + convert_id_response = ConvertId(Authentication.credentials[0], item_id, id_type=response[0], convert_to=response[1]).run() get_item_response = GetItem(convert_id_response[0]).run() return get_item_response if get_item_response else None return GetItem(item_id, change_key=change_key).run() @@ -76,3 +102,22 @@ def sync_folder_items(self, folder_id, change_key=None): def create_item(self, subject, sender, to_recipients, body_type='HTML'): return CreateItem(**{'Subject': subject, 'BodyType': body_type, 'Sender': sender, 'ToRecipients': to_recipients}).run() + + def delete_item(self, item_id, delete_type='MoveToDeletedItems'): + if not isinstance(item_id, list): + item_id = [item_id] + for item in item_id: + deleted_item = DeleteItem(item, delete_type=delete_type).run() + + def search_and_delete_message(self, query, thread_count=10, what_if=False): + reference_id_list = [] + for mailbox in self.get_searchable_mailboxes(): + reference_id_list.append(mailbox.get('reference_id')) + count = 1 + for item in self.execute_ews_search(query, reference_id_list, thread_count=thread_count): + if count == 1: + if what_if: + print('WHAT IF: About to delete message ID: {}'.format(item.get('id').get('id'))) + else: + self.delete_item(item.get('id').get('id')) + count += 1 From 79eb65e5369e664fad76a72dcb09aa20be9b80c6 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 15:57:52 -0500 Subject: [PATCH 22/72] Refactored Authentication class --- pyews/core/authentication.py | 84 ++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/pyews/core/authentication.py b/pyews/core/authentication.py index 4bb3724..84b5cdd 100644 --- a/pyews/core/authentication.py +++ b/pyews/core/authentication.py @@ -2,85 +2,87 @@ from .core import Core -class classproperty(property): - def __get__(self, obj, objtype=None): - return super(classproperty, self).__get__(objtype) - def __set__(self, obj, value): - super(classproperty, self).__set__(type(obj), value) - def __delete__(self, obj): - super(classproperty, self).__delete__(type(obj)) +class AuthenticationProperties(type): + def __set_initial_property_values(cls): + if isinstance(cls._credentials, tuple): + cls.domain = cls._credentials[0] + cls.endpoints = None + cls.exchange_versions = None -class Authentication(Core): - - def __init__(cls, username, password, ews_url=None, exchange_version=ExchangeVersion.EXCHANGE_VERSIONS, impersonate_as=None): - cls.impersonate_as = impersonate_as - cls.exchange_versions = exchange_version - cls.credentials = (username, password) - cls.domain = username - cls.endpoints = ews_url - - @classproperty + @property def impersonate_as(cls): - return cls.__impersonate_as - + if not cls._impersonate_as: + cls._impersonate_as = '' + return cls._impersonate_as + @impersonate_as.setter def impersonate_as(cls, value): if not value: - cls.__impersonate_as = '' + cls._impersonate_as = '' else: - cls.__impersonate_as = value + cls._impersonate_as = value - @classproperty + @property def credentials(cls): return cls._credentials @credentials.setter def credentials(cls, value): if isinstance(value, tuple): - cls.domain = value[0] cls._credentials = value + cls.__set_initial_property_values() else: raise AttributeError('Please provide both a username and password') - @classproperty + @property def exchange_versions(cls): return cls._exchange_versions @exchange_versions.setter def exchange_versions(cls, value): - from .exchangeversion import ExchangeVersion if not value: - cls._exchange_versions = ExchangeVersion.EXCHANGE_VERSIONS - elif not isinstance(value, list): - cls._exchange_versions = [value] - else: - cls._exchange_versions = value + from .exchangeversion import ExchangeVersion + value = ExchangeVersion.EXCHANGE_VERSIONS + if not isinstance(value, list): + value = [value] + cls._exchange_versions = value - @classproperty + @property def endpoints(cls): return cls._endpoints @endpoints.setter def endpoints(cls, value): - from .endpoints import Endpoints if not value: - cls._endpoints = Endpoints(cls.domain).get() + from .endpoints import Endpoints + cls._endpoints = Endpoints(cls._domain).get() elif not isinstance(value, list): cls._endpoints = [value] else: cls._endpoints = value - @classproperty + @property def domain(cls): return cls._domain @domain.setter def domain(cls, value): - '''Splits the domain from an email address - - Returns: - str: Returns the split domain from an email address - ''' - local, _, domain = value.partition('@') - cls._domain = domain + temp_val = None + if '@' in value: + local, _, domain = value.partition('@') + temp_val = domain + elif value: + temp_val = value + else: + temp_val = None + cls._domain = temp_val + + +class Authentication(object, metaclass=AuthenticationProperties): + + _impersonate_as = None + _credentials = tuple() + _exchange_versions = [] + _endpoints = [] + _domain = None From 21c9bbd28a9689a8dbad9b1d6f642dcdfcae4c3e Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 15:58:17 -0500 Subject: [PATCH 23/72] Added additional properties to getusersettings operation --- pyews/endpoint/getusersettings.py | 54 ++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/pyews/endpoint/getusersettings.py b/pyews/endpoint/getusersettings.py index 960248c..290ed71 100644 --- a/pyews/endpoint/getusersettings.py +++ b/pyews/endpoint/getusersettings.py @@ -35,8 +35,60 @@ def soap(self): self.A_NAMESPACE.Setting('InternalMailboxServer'), self.A_NAMESPACE.Setting('MailboxDN'), self.A_NAMESPACE.Setting('ActiveDirectoryServer'), - self.A_NAMESPACE.Setting('MailboxDN'), self.A_NAMESPACE.Setting('EwsSupportedSchemas'), + self.A_NAMESPACE.Setting('InternalRpcClientServer'), + self.A_NAMESPACE.Setting('InternalEcpUrl'), + self.A_NAMESPACE.Setting('InternalEcpVoicemailUrl'), + self.A_NAMESPACE.Setting('InternalEcpEmailSubscriptionsUrl'), + self.A_NAMESPACE.Setting('InternalEcpTextMessagingUrl'), + self.A_NAMESPACE.Setting('InternalEcpDeliveryReportUrl'), + self.A_NAMESPACE.Setting('InternalEcpRetentionPolicyTagsUrl'), + self.A_NAMESPACE.Setting('InternalEcpPublishingUrl'), + self.A_NAMESPACE.Setting('InternalOABUrl'), + self.A_NAMESPACE.Setting('InternalUMUrl'), + self.A_NAMESPACE.Setting('InternalWebClientUrls'), + self.A_NAMESPACE.Setting('PublicFolderServer'), + self.A_NAMESPACE.Setting('ExternalMailboxServer'), + self.A_NAMESPACE.Setting('ExternalMailboxServerRequiresSSL'), + self.A_NAMESPACE.Setting('ExternalMailboxServerAuthenticationMethods'), + self.A_NAMESPACE.Setting('EcpVoicemailUrlFragment'), + self.A_NAMESPACE.Setting('EcpEmailSubscriptionsUrlFragment'), + self.A_NAMESPACE.Setting('EcpTextMessagingUrlFragment'), + self.A_NAMESPACE.Setting('EcpDeliveryReportUrlFragment'), + self.A_NAMESPACE.Setting('EcpRetentionPolicyTagsUrlFragment'), + self.A_NAMESPACE.Setting('ExternalEcpUrl'), + self.A_NAMESPACE.Setting('EcpPublishingUrlFragment'), + self.A_NAMESPACE.Setting('ExternalEcpVoicemailUrl'), + self.A_NAMESPACE.Setting('ExternalEcpEmailSubscriptionsUrl'), + self.A_NAMESPACE.Setting('ExternalEcpTextMessagingUrl'), + self.A_NAMESPACE.Setting('ExternalEcpDeliveryReportUrl'), + self.A_NAMESPACE.Setting('EcpEmailSubscriptionsUrlFragment'), + self.A_NAMESPACE.Setting('ExternalEcpRetentionPolicyTagsUrl'), + self.A_NAMESPACE.Setting('ExternalEcpPublishingUrl'), + self.A_NAMESPACE.Setting('ExternalOABUrl'), + self.A_NAMESPACE.Setting('ExternalUMUrl'), + self.A_NAMESPACE.Setting('ExternalWebClientUrls'), + self.A_NAMESPACE.Setting('CrossOrganizationSharingEnabled'), + self.A_NAMESPACE.Setting('AlternateMailboxes'), + self.A_NAMESPACE.Setting('CasVersion'), + self.A_NAMESPACE.Setting('InternalPop3Connections'), + self.A_NAMESPACE.Setting('ExternalPop3Connections'), + self.A_NAMESPACE.Setting('InternalImap4Connections'), + self.A_NAMESPACE.Setting('ExternalImap4Connections'), + self.A_NAMESPACE.Setting('InternalSmtpConnections'), + self.A_NAMESPACE.Setting('ExternalSmtpConnections'), + self.A_NAMESPACE.Setting('InternalServerExclusiveConnect'), + self.A_NAMESPACE.Setting('ExternalServerExclusiveConnect'), + self.A_NAMESPACE.Setting('ExchangeRpcUrl'), + self.A_NAMESPACE.Setting('ShowGalAsDefaultView'), + self.A_NAMESPACE.Setting('AutoDiscoverSMTPAddress'), + self.A_NAMESPACE.Setting('InteropExternalEwsUrl'), + self.A_NAMESPACE.Setting('ExternalEwsVersion'), + self.A_NAMESPACE.Setting('InteropExternalEwsVersion'), + self.A_NAMESPACE.Setting('MobileMailboxPolicyInterop'), + self.A_NAMESPACE.Setting('GroupingInformation'), + self.A_NAMESPACE.Setting('UserMSOnline'), + self.A_NAMESPACE.Setting('MapiHttpEnabled') ) ), ) From 4c59beb1ee890fe99bf484a1d907def12f69932e Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 15:58:43 -0500 Subject: [PATCH 24/72] Added Authentication import for Autodiscover --- pyews/service/autodiscover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyews/service/autodiscover.py b/pyews/service/autodiscover.py index 0042247..22279bb 100644 --- a/pyews/service/autodiscover.py +++ b/pyews/service/autodiscover.py @@ -1,4 +1,4 @@ -from .base import Base, ElementMaker, abc, etree +from .base import Base, ElementMaker, abc, etree, Authentication class Autodiscover(Base): @@ -23,7 +23,7 @@ class Autodiscover(Base): @property def to(self): - return self.credentials[0] + return Authentication.credentials[0] def get(self, exchange_version): self.__logger.info('Building Autodiscover SOAP request for {current}'.format(current=self.__class__.__name__)) From 33f918ab050bb8b527ffdd9bbc3ffa081bd24972 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 15:59:07 -0500 Subject: [PATCH 25/72] Modified base class to use Authentication static properties --- pyews/service/base.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pyews/service/base.py b/pyews/service/base.py index f56c871..b1b0c1b 100644 --- a/pyews/service/base.py +++ b/pyews/service/base.py @@ -65,10 +65,10 @@ def get(self): raise NotImplementedError def _impersonation_header(self): - if self.impersonate_as: + if hasattr(Authentication, 'impersonate_as') and Authentication.impersonate_as: return self.T_NAMESPACE.ExchangeImpersonation( self.T_NAMESPACE.ConnectingSID( - self.T_NAMESPACE.PrimarySmtpAddress(self.impersonate_as) + self.T_NAMESPACE.PrimarySmtpAddress(Authentication.impersonate_as) ) ) return '' @@ -98,8 +98,8 @@ def run(self): if hasattr(Authentication, item): setattr(self, item, getattr(Authentication, item)) - for endpoint in self.endpoints: - for version in self.exchange_versions: + for version in Authentication.exchange_versions: + for endpoint in Authentication.endpoints: if self.__class__.__base__.__name__ == 'Operation' and 'autodiscover' in endpoint: self.__logger.debug('{} == Operation so skipping endpoint {}'.format(self.__class__.__base__.__name__, endpoint)) continue @@ -108,23 +108,23 @@ def run(self): continue try: self.__logger.info('Sending SOAP request to {}'.format(endpoint)) + self.__logger.info('Setting Exchange Version header to {}'.format(version)) response = requests.post( url=endpoint, data=self.get(version).decode("utf-8"), headers=self.SOAP_REQUEST_HEADER, - auth=self.credentials, + auth=Authentication.credentials, verify=True ) - self.__logger.debug('Response HTTP status code: %s', response.status_code) - self.__logger.debug('Response text: %s', response.text) + self.__logger.debug('Response HTTP status code: {}'.format(response.status_code)) + self.__logger.debug('Response text: {}'.format(response.text)) parsed_response = BeautifulSoup(response.content, 'xml') if not parsed_response.contents: self.__logger.warning( 'The server responded with empty content to POST-request ' 'from {current}'.format(current=self.__class__.__name__)) - return response_code = getattr(parsed_response.find('ResponseCode'), 'string', None) error_code = getattr(parsed_response.find('ErrorCode'), 'string', None) @@ -148,7 +148,6 @@ def run(self): 'response code to POST-request from {current} with error message "{message_text}"'.format( current=self.__class__.__name__, message_text=message_text)) - return None elif 'ErrorInvalidIdMalformed' in (response_code, error_code): self.__logger.warning( 'The server responded with "ErrorInvalidIdMalformed" ' @@ -170,5 +169,6 @@ def run(self): 'and "ErrorCode" from {current} with error message "{message_text}"'.format( current=self.__class__.__name__, message_text=message_text)) + continue except: pass From 95d420985c6c2060eb5ac8af685efd878c224f1f Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 15:59:24 -0500 Subject: [PATCH 26/72] Added lots of new tests --- tests/conftest.py | 12 +++++++ tests/test_authentication.py | 24 ++++++++++++++ tests/test_autodiscover.py | 31 ++++++++++++++++++ tests/test_core.py | 57 +++++++++++++++++++++++++++++++++ tests/test_endpoints.py | 11 +++++++ tests/test_ews.py | 13 ++++++++ tests/test_exchangeversion.py | 16 +++++++++ tests/test_operation.py | 27 ++++++++++++++++ tests/test_userconfiguration.py | 20 ------------ 9 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_authentication.py create mode 100644 tests/test_autodiscover.py create mode 100644 tests/test_core.py create mode 100644 tests/test_endpoints.py create mode 100644 tests/test_ews.py create mode 100644 tests/test_exchangeversion.py create mode 100644 tests/test_operation.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..951d552 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +import os +import pytest + +@pytest.fixture +def pyews_fixture(): + import pyews + yield pyews + +@pytest.fixture +def pyews_ews_interface(): + from pyews import EWS + yield EWS diff --git a/tests/test_authentication.py b/tests/test_authentication.py new file mode 100644 index 0000000..532d05f --- /dev/null +++ b/tests/test_authentication.py @@ -0,0 +1,24 @@ +def test_authentication_init(): + from pyews import Authentication, ExchangeVersion, Endpoints + Authentication.credentials = ('user@company.com','mypassword') + assert isinstance(Authentication.credentials, tuple) + assert isinstance(Authentication.exchange_versions, list) + assert Authentication.exchange_versions == ExchangeVersion.EXCHANGE_VERSIONS + assert Authentication.endpoints == Endpoints('company.com').get() + assert Authentication.domain == 'company.com' + assert Authentication.impersonate_as == '' + +def test_setting_authentication_details_directly(): + from pyews import Authentication + Authentication.credentials = ('user@company.com','mypassword') + assert Authentication.credentials == ('user@company.com','mypassword') + Authentication.exchange_versions = 'Exchange2015' + assert Authentication.exchange_versions == ['Exchange2015'] + Authentication.endpoints = 'https://outlook.office365.com/EWS/Exchange.asmx' + assert Authentication.endpoints == ['https://outlook.office365.com/EWS/Exchange.asmx'] + Authentication.endpoints = ['https://outlook.office365.com/EWS/Exchange.asmx','https://outlook.office365.com/autodiscover/autodiscover.svc'] + assert Authentication.endpoints == ['https://outlook.office365.com/EWS/Exchange.asmx', 'https://outlook.office365.com/autodiscover/autodiscover.svc'] + Authentication.domain = 'testcompany.com' + assert Authentication.domain == 'testcompany.com' + Authentication.domain = 'first.last@testcompany.com' + assert Authentication.domain == 'testcompany.com' \ No newline at end of file diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py new file mode 100644 index 0000000..b7a0532 --- /dev/null +++ b/tests/test_autodiscover.py @@ -0,0 +1,31 @@ +from re import A +from bs4 import BeautifulSoup +from pyews import Autodiscover, Authentication + + +class TestAutodiscover(Autodiscover): + + def soap(self): + return self.A_NAMESPACE.TestAutodiscoverElement('Some Autodiscover Test Value') + + +def test_autodiscover_soap_body(): + test_operation = TestAutodiscover() + assert test_operation.run() == None + soap_body = test_operation.get('Exchange2016') + soap = BeautifulSoup(soap_body, 'xml') + + envelope = soap.Envelope + assert envelope['xmlns:wsa'] == Autodiscover.AUTODISCOVER_MAP['wsa'] + assert envelope['xmlns:xsi'] == Autodiscover.AUTODISCOVER_MAP['xsi'] + assert envelope['xmlns:soap'] == Autodiscover.AUTODISCOVER_MAP['soap'] + assert envelope['xmlns:a'] == Autodiscover.AUTODISCOVER_MAP['a'] + + assert soap.Header.RequestedServerVersion.string == 'Exchange2016' + assert soap.select('a|RequestedServerVersion')[0].string == 'Exchange2016' + assert soap.Header.Action.string == 'http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/TestAutodiscover' + assert soap.select('wsa|Action')[0].string == 'http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/TestAutodiscover' + assert soap.Header.To.string == Authentication.credentials[0] + assert soap.select('wsa|To')[0].string == Authentication.credentials[0] + assert soap.find_all('TestAutodiscoverElement')[0].string == 'Some Autodiscover Test Value' + assert soap.select('a|TestAutodiscoverElement')[0].string == 'Some Autodiscover Test Value' diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..7458a96 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,57 @@ +from bs4 import BeautifulSoup + +snyc_folder_items_response = """ + + + + + + + + + NoError + H4sIAAAAA= + true + + + + + Budget Q3 + Normal + false + true + NoResponseReceived + Busy + 2006-08-02T17:30:00Z + 2006-08-02T19:30:00Z + Conference Room 2 + + + Dan Park + dpark@example.com + SMTP + + + + + + + + + + +""" + + +def test_core_can_parse_soap_response(): + from pyews import Core + core = Core() + soap_response = BeautifulSoup(snyc_folder_items_response, 'xml') + assert isinstance(core.parse_response(soap_response), dict) + diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py new file mode 100644 index 0000000..add439e --- /dev/null +++ b/tests/test_endpoints.py @@ -0,0 +1,11 @@ +def test_endpoints(pyews_fixture): + from pyews import Endpoints + domain = 'example.com' + endpoints = Endpoints(domain=domain).get() + if isinstance(endpoints, list): + assert True + if len(endpoints) >= 5: + assert True + if domain in endpoints[3] and domain in endpoints[4]: + assert True + diff --git a/tests/test_ews.py b/tests/test_ews.py new file mode 100644 index 0000000..d384dd0 --- /dev/null +++ b/tests/test_ews.py @@ -0,0 +1,13 @@ + +def test_instantiation_of_ews_interface(pyews_ews_interface): + ews = pyews_ews_interface( + 'username@company.com', + 'mypassword1' + ) + assert ews.multi_threading == False + from pyews import Authentication + assert Authentication.credentials == ('username@company.com','mypassword1') + assert Authentication.domain == 'company.com' + assert isinstance(Authentication.endpoints, list) + assert isinstance(Authentication.exchange_versions, list) + assert Authentication.impersonate_as is '' diff --git a/tests/test_exchangeversion.py b/tests/test_exchangeversion.py new file mode 100644 index 0000000..d6f3174 --- /dev/null +++ b/tests/test_exchangeversion.py @@ -0,0 +1,16 @@ +def test_exchange_version(): + from pyews import ExchangeVersion + version = ExchangeVersion('15.20.4.3') + if version.exchange_version == 'Exchange2016': + assert True + try: + version = ExchangeVersion('14.20.4.2') + except: + assert True + if ExchangeVersion.valid_version('Exchange2019'): + assert True + if ExchangeVersion.valid_version('Office365'): + assert True + if not ExchangeVersion.valid_version('Exchange2007'): + assert True + diff --git a/tests/test_operation.py b/tests/test_operation.py new file mode 100644 index 0000000..ae5157c --- /dev/null +++ b/tests/test_operation.py @@ -0,0 +1,27 @@ +from bs4 import BeautifulSoup +from pyews import Operation + + +class TestOperation(Operation): + + def soap(self): + return Operation.M_NAMESPACE.TestElement('Some Test Value') + + +def test_operation_soap_body(): + test_operation = TestOperation() + assert test_operation.run() == None + soap_body = test_operation.get('Exchange2016') + soap = BeautifulSoup(soap_body, 'xml') + + envelope = soap.Envelope + assert envelope['xmlns:m'] == Operation.NAMESPACE_MAP['m'] + assert envelope['xmlns:soap'] == Operation.NAMESPACE_MAP['soap'] + assert envelope['xmlns:t'] == Operation.NAMESPACE_MAP['t'] + assert envelope['xmlns:xs'] == "http://www.w3.org/2001/XMLSchema" + assert envelope['xmlns:xsi'] == "http://www.w3.org/2001/XMLSchema-instance" + + assert soap.Header.RequestedServerVersion['Version'] == 'Exchange2016' + assert soap.select('t|RequestedServerVersion')[0]['Version'] == 'Exchange2016' + assert soap.find_all('TestElement')[0].string == 'Some Test Value' + assert soap.select('m|TestElement')[0].string == 'Some Test Value' diff --git a/tests/test_userconfiguration.py b/tests/test_userconfiguration.py index 7ec34df..e69de29 100644 --- a/tests/test_userconfiguration.py +++ b/tests/test_userconfiguration.py @@ -1,20 +0,0 @@ -import unittest -from pyews import UserConfiguration - -class TestUserConfiguration(unittest.TestCase): - - username = 'first.last@dev.onmicrosoft.com' - password = 'password' - - def test_credentials_username(self): - result = UserConfiguration(self.username, self.password) - self.assertEqual(result.credentials.email_address, self.username) - - def test_credentials_password(self): - result = UserConfiguration(self.username, self.password) - self.assertEqual(result.credentials.password, self.password) - - - -if __name__ == '__main__': - unittest.main() From 00d9a16b6cd22fd6095abf674ac60b0057cec7cc Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 16:19:10 -0500 Subject: [PATCH 27/72] Added ability to retrieve the SOAP request body in when debug logging enabled --- pyews/service/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyews/service/base.py b/pyews/service/base.py index b1b0c1b..7ce1b82 100644 --- a/pyews/service/base.py +++ b/pyews/service/base.py @@ -109,9 +109,11 @@ def run(self): try: self.__logger.info('Sending SOAP request to {}'.format(endpoint)) self.__logger.info('Setting Exchange Version header to {}'.format(version)) + body = self.get(version).decode("utf-8") + self.__logger.debug('EWS SOAP Request Body: {}'.format(body)) response = requests.post( url=endpoint, - data=self.get(version).decode("utf-8"), + data=body, headers=self.SOAP_REQUEST_HEADER, auth=Authentication.credentials, verify=True From 60291baf83c0b41a0dc40e378ee77a800775b3a3 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 16:19:30 -0500 Subject: [PATCH 28/72] imported Authentication in GetUserSettings --- pyews/endpoint/getusersettings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyews/endpoint/getusersettings.py b/pyews/endpoint/getusersettings.py index 290ed71..529096a 100644 --- a/pyews/endpoint/getusersettings.py +++ b/pyews/endpoint/getusersettings.py @@ -1,4 +1,4 @@ -from ..service.autodiscover import Autodiscover +from ..service.autodiscover import Autodiscover, Authentication class GetUserSettings(Autodiscover): @@ -18,7 +18,7 @@ def __init__(self, user=None): def soap(self): if not self.user: - self.user = self.credentials[0] + self.user = Authentication.credentials[0] return self.A_NAMESPACE.GetUserSettingsRequestMessage( self.A_NAMESPACE.Request( self.A_NAMESPACE.Users( From ba339926c4e0de8f8827d55744f15e4eb9ae6dfb Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 16:20:03 -0500 Subject: [PATCH 29/72] Added GetDomainSettings Operation --- pyews/endpoint/getdomainsettings.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 pyews/endpoint/getdomainsettings.py diff --git a/pyews/endpoint/getdomainsettings.py b/pyews/endpoint/getdomainsettings.py new file mode 100644 index 0000000..c058892 --- /dev/null +++ b/pyews/endpoint/getdomainsettings.py @@ -0,0 +1,29 @@ +from ..service.autodiscover import Autodiscover, Authentication + + +class GetDomainSettings(Autodiscover): + """GetFederationInformation EWS Autodiscover endpoint + retrieves the authenticated users federation information + """ + + def __init__(self, domain=None): + """Retrieves the domain settings for the authenticated or provided domain. + + Args: + user (str, optional): A user to retrieve user settings for. Defaults to None. + """ + self.domain = domain + + def soap(self): + return self.A_NAMESPACE.GetDomainSettingsRequestMessage( + self.A_NAMESPACE.Request( + self.A_NAMESPACE.Domains( + self.A_NAMESPACE.Domain(Authentication.credentials[0].split('@')[-1]) + ), + self.A_NAMESPACE.RequestedSettings( + self.A_NAMESPACE.Setting('InternalEwsUrl'), + self.A_NAMESPACE.Setting('ExternalEwsUrl'), + + ) + ) + ) From c493e4eb1ab7a4f2b9daba79d7e6821679a4c81c Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 16:20:18 -0500 Subject: [PATCH 30/72] Added method in interface for GetDomainSettings --- pyews/ews.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyews/ews.py b/pyews/ews.py index 0380dc7..08c1b7c 100644 --- a/pyews/ews.py +++ b/pyews/ews.py @@ -1,7 +1,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from .core import Authentication -from .endpoint import GetSearchableMailboxes, GetUserSettings, ResolveNames, SearchMailboxes, ExecuteSearch, GetInboxRules, GetItem, ConvertId, GetHiddenInboxRules, CreateItem, GetServiceConfiguration, SyncFolderHierarchy, SyncFolderItems, GetAttachment, DeleteItem +from .endpoint import GetSearchableMailboxes, GetUserSettings, ResolveNames, SearchMailboxes, ExecuteSearch, GetInboxRules, GetItem, ConvertId, GetHiddenInboxRules, CreateItem, GetServiceConfiguration, SyncFolderHierarchy, SyncFolderItems, GetAttachment, DeleteItem, GetDomainSettings class EWS: @@ -121,3 +121,6 @@ def search_and_delete_message(self, query, thread_count=10, what_if=False): else: self.delete_item(item.get('id').get('id')) count += 1 + + def get_domain_settings(self, domain=None): + return GetDomainSettings(domain=domain).run() From fa2ae83b4d95d671c183cb949a9f9620129429b9 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 16:20:42 -0500 Subject: [PATCH 31/72] Renamed variable in exchangeversion class --- pyews/core/exchangeversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyews/core/exchangeversion.py b/pyews/core/exchangeversion.py index 8f0df70..b316fbb 100644 --- a/pyews/core/exchangeversion.py +++ b/pyews/core/exchangeversion.py @@ -55,7 +55,7 @@ class ExchangeVersion: EXCHANGE_VERSIONS = ['Exchange2019', 'Exchange2016', 'Exchange2015', 'Exchange2013_SP1', 'Exchange2013', 'Exchange2010_SP2', 'Exchange2010_SP1', 'Exchange2010'] def __init__(self, version): - self.exchangeVersion = self._get_api_version(version) + self.exchange_version = self._get_api_version(version) def _get_api_version(self, version): '''Gets a string representation of an Exchange Version number From 041d364e5330623b639feb72c3318d7663af93c8 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 25 May 2021 16:20:52 -0500 Subject: [PATCH 32/72] Exported GetDomainSettings --- pyews/endpoint/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyews/endpoint/__init__.py b/pyews/endpoint/__init__.py index ae5b350..8e2a210 100644 --- a/pyews/endpoint/__init__.py +++ b/pyews/endpoint/__init__.py @@ -12,4 +12,5 @@ from .syncfolderhierarchy import SyncFolderHierarchy from .syncfolderitems import SyncFolderItems from .createitem import CreateItem -from .getserviceconfiguration import GetServiceConfiguration \ No newline at end of file +from .getserviceconfiguration import GetServiceConfiguration +from .getdomainsettings import GetDomainSettings \ No newline at end of file From 29059069b3c15eab08ea09b2603e1456bef1c7fb Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 4 Jun 2021 22:02:04 -0500 Subject: [PATCH 33/72] Removed test --- tests/test_userconfiguration.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/test_userconfiguration.py diff --git a/tests/test_userconfiguration.py b/tests/test_userconfiguration.py deleted file mode 100644 index e69de29..0000000 From 192859319069775e26a93812824242bfb1eb3225 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 4 Jun 2021 22:03:16 -0500 Subject: [PATCH 34/72] Bumped version to 3.0.2 --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 337c25d..2bb21e8 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ def parse_requirements(requirement_file): setup( name='py-ews-dev', - version='3.0.1', + version='3.0.2', packages=find_packages(exclude=['tests*']), license='MIT', description='A Python package to interact with both on-premises and Office 365 Exchange Web Services', @@ -17,7 +17,7 @@ def parse_requirements(requirement_file): url='https://github.com/swimlane/pyews', author='Swimlane', author_email='info@swimlane.com', - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*, <4', + python_requires='>=3.6, <4', package_data={ 'pyews': ['data/logging.yml'] }, From 0c00bb07926712b50194ff804da24ce6756200fe Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 4 Jun 2021 22:03:33 -0500 Subject: [PATCH 35/72] Modified base to include OAuth2 headers in requests --- pyews/service/base.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyews/service/base.py b/pyews/service/base.py index 7ce1b82..11d08d0 100644 --- a/pyews/service/base.py +++ b/pyews/service/base.py @@ -111,10 +111,16 @@ def run(self): self.__logger.info('Setting Exchange Version header to {}'.format(version)) body = self.get(version).decode("utf-8") self.__logger.debug('EWS SOAP Request Body: {}'.format(body)) + if Authentication.auth_header: + header_dict = Authentication.auth_header + header_dict.update(self.SOAP_REQUEST_HEADER) + else: + header_dict = self.SOAP_REQUEST_HEADER + self.__logger.debug(f"Headers: {header_dict}") response = requests.post( url=endpoint, data=body, - headers=self.SOAP_REQUEST_HEADER, + headers=header_dict, auth=Authentication.credentials, verify=True ) From 14c65562c25401d87786a3f98a565f14a5b78c83 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 4 Jun 2021 22:03:50 -0500 Subject: [PATCH 36/72] Added ability to use OAuth2 (multiple grant flows) with pyews --- pyews/core/oauthconnector.py | 230 +++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 pyews/core/oauthconnector.py diff --git a/pyews/core/oauthconnector.py b/pyews/core/oauthconnector.py new file mode 100644 index 0000000..c13c77c --- /dev/null +++ b/pyews/core/oauthconnector.py @@ -0,0 +1,230 @@ +from oauthlib.oauth2.rfc6749.errors import InvalidGrantError +from xmltodict import parse +import requests, pendulum +import json +from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse + +from oauthlib.oauth2 import LegacyApplicationClient +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2Session + +#__AUTHORITY_URL__ = 'https://login.microsoftonline.com/{{tenant}}' +#__TOKEN_ENDPOINT__ = '/oauth2/v2.0/token' +#__API_VERSION__ = 'v1.0' +#__BASE_URL__ = 'https://graph.microsoft.com' + + + +class OAuth2Connector: + """GraphConnector is the main authentication mechanism for Microsoft Graph OAuth2 Authentication + """ + AUTH_MAP = { + 'v1': { + 'authorize_url': 'https://login.microsoftonline.com/{tenant_id}/oauth2/authorize', + 'token_url': 'https://login.microsoftonline.com/{tenant_id}/oauth2/token', + 'resource': 'https://outlook.office365.com' + }, + 'v2': { + 'authorize_url': 'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize', + 'token_url': 'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token', + 'scope': 'https://outlook.office365.com/EWS.AccessAsUser.All' + } + } + + def __init__(self, endpoint_version='v1'): + """GraphConnector is the base (parent) class of both Search and Delete classes. It is used to perform either delegated authentication flows + like: (Single-Page, Web Apps, Mobile & Native Apps - Grant Auth Flow) or you can use it in the application authentication auth flows like: (Client Credentials Grant Auth Flow) + + + Args: + client_id (str): Your Azure AD Application client ID + client_secret (str): Your Azure AD Application client secret + tenant_id (str): Your Azure AD tenant ID + username (str, optional): A username used to authenticate to Azure or Office 365. Defaults to None. If provided, will use delegated authentication flows + password (str, optional): The password used to authenticate to Azure or Office 365. Defaults to None. If provided, will use delegated authentication flows + scopes (list, optional): A list of scopes defined during your Azure AD application registration. Defaults to ['https://graph.microsoft.com/.default']. + verify_ssl (bool, optional): Whether to verify SSL or not. Defaults to True. + """ + from .authentication import Authentication + self.endpoint_version = endpoint_version + self.verify = True + self.username = Authentication.credentials[0] + self.password = Authentication.credentials[1] + self.client_id = Authentication.client_id + self.client_secret = Authentication.client_secret + self.tenant_id = Authentication.tenant_id + self.access_token = Authentication.access_token + self.redirect_uri = Authentication.redirect_uri + self.authorize_url = self.AUTH_MAP.get(endpoint_version).get('authorize_url').format(tenant_id=self.tenant_id) + self.token_url = self.AUTH_MAP.get(endpoint_version).get('token_url').format(tenant_id=self.tenant_id) + if endpoint_version == 'v1': + self.resource = self.AUTH_MAP.get(endpoint_version).get('resource') + else: + self.resource = None + if endpoint_version == 'v2': + self.scope = [self.AUTH_MAP.get(endpoint_version).get('scope')] + else: + self.scope = None + self.session = requests.Session() + self.session.headers = { + 'Content-Type': 'application/x-www-form-urlencoded' + } + self.session.verify = self.verify + self.expiration = None + #if not self.access_token: + # if self.client_secret and self.client_id and self.tenant_id and self.username and self.password: + # if self.scope or self.resource: + # self.legacy_app_flow() + # if self.client_secret and self.client_id and self.tenant_id and self.redirect_uri and self.scope: + # self.auth_code_grant() + # elif self.client_secret and self.client_id and self.tenant_id: + # if self.scope or self.resource: + # self.client_credentials_grant() + # self.backend_app_flow() + # else: + # self.web_application_flow() + # else: + # self.implicit_grant_flow() + + def __prompt_user(self, url, full_response=False): + print('Please go here and authorize: ', url) + response = input('Paste the full redirect URL here:') + if full_response: + return response + if 'code=' in response: + return response.split('code=')[-1].split('&')[0] + elif 'id_token=' in response: + return response.split('id_token=')[-1].split('&')[0] + + def __build_query_param_url(self, url, params): + url_parse = urlparse(url) + query = url_parse.query + url_dict = dict(parse_qsl(query)) + url_dict.update(params) + url_new_query = urlencode(url_dict) + url_parse = url_parse._replace(query=url_new_query) + return urlunparse(url_parse) + + def auth_code_grant(self): + """Authorization Code Flow Grant + Reference: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow + """ + param = { + 'response_type': 'code', + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'state': '1234' + } + url = self.__build_query_param_url(self.authorize_url, param) + authorization_code = self.__prompt_user(url) + body = f''' +grant_type=authorization_code +&code={authorization_code} +&client_id={self.client_id} +&client_secret={self.client_secret} +&scope={self.scope} +&redirect_uri={self.redirect_uri} +''' + response = self.session.request('POST', self.token_url, data=body) + return response.json().get('access_token') + + def client_credentials_grant(self): + """Client Credentials Code Flow Grant + Reference: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow + """ + if self.resource: + body = { + 'resource' : self.resource, + 'client_id' : self.client_id, + 'client_secret' : self.client_secret, + 'grant_type' : 'client_credentials' + } + else: + body = { + 'scope' : self.scope, + 'client_id' : self.client_id, + 'client_secret' : self.client_secret, + 'grant_type' : 'client_credentials' + } + response = self.session.request('POST', self.token_url, data=body).json() + return response['access_token'] + + def implicit_grant_flow(self): + """Implicit Grant Flow + Reference: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-implicit-grant-flow + """ + params = { + 'client_id': self.client_id, + 'response_type': 'id_token', + 'redirect_uri': self.redirect_uri, + 'scope': 'openid', + 'response_mode': 'fragment', + 'state': 1234, + 'nonce': 678910 + } + url = self.__build_query_param_url(self.authorize_url, params) + return self.__prompt_user(url) + + def web_application_flow(self): + oauth = OAuth2Session( + client_id=self.client_id, + redirect_uri='http://google.com', + scope=self.scope + ) + authorization_url, state = oauth.authorization_url(self.authorize_url) + token = oauth.fetch_token( + self.authority_url, + client_secret=self.client_secret, + authorization_response=self.__prompt_user(authorization_url, full_response=True) + ) + return token + + def legacy_app_flow(self): + """Resource Ownwer Password Credentials Grant Flow + Reference: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth-ropc + """ + oauth = OAuth2Session(client=LegacyApplicationClient(client_id=self.client_id)) + try: + if self.endpoint_version == 'v1': + token = oauth.fetch_token( + token_url=self.token_url, + username=self.username, + password=self.password, + client_id=self.client_id, + client_secret=self.client_secret, + resource=self.resource, + verify=self.verify + ) + else: + token = oauth.fetch_token( + token_url=self.token_url, + username=self.username, + password=self.password, + client_id=self.client_id, + client_secret=self.client_secret, + scope=self.scope, + verify=self.verify + ) + except InvalidGrantError as e: + print(e) + raise Exception('Please use another authorization method. I suggest trying the auth_code_grant() method.') + return token['access_token'] + + def backend_app_flow(self): + client = BackendApplicationClient(client_id=self.client_id) + oauth = OAuth2Session(client=client) + if self.endpoint_version == 'v1': + token = oauth.fetch_token( + token_url=self.token_url, + client_id=self.client_id, + client_secret=self.client_secret, + resource=self.resource + ) + else: + token = oauth.fetch_token( + token_url=self.token_url, + client_id=self.client_id, + client_secret=self.client_secret, + scope=self.scope + ) + return token['access_token'] From 902cbe1cd49b05806c32fc724cd0592032329003 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 4 Jun 2021 22:04:10 -0500 Subject: [PATCH 37/72] Added Authentication properties for OAuth2 authentication --- pyews/core/authentication.py | 93 ++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/pyews/core/authentication.py b/pyews/core/authentication.py index 84b5cdd..1c46a0c 100644 --- a/pyews/core/authentication.py +++ b/pyews/core/authentication.py @@ -1,5 +1,7 @@ +from os import access from .exchangeversion import ExchangeVersion from .core import Core +from .oauthconnector import OAuth2Connector class AuthenticationProperties(type): @@ -7,8 +9,92 @@ class AuthenticationProperties(type): def __set_initial_property_values(cls): if isinstance(cls._credentials, tuple): cls.domain = cls._credentials[0] + if cls.impersonate_as is not '': + cls.auth_header = cls._credentials[0] + else: + cls.auth_header = None cls.endpoints = None cls.exchange_versions = None + if cls.tenant_id and cls.client_id and cls.client_secret: + if not cls.oauth2_authorization_type: + print('Please provide an OAuth2 Authorization Types before continuing') + else: + try: + cls.access_token = getattr(OAuth2Connector(), cls.oauth2_authorization_type)() + except: + cls.access_token = getattr(OAuth2Connector(endpoint_version='v2'), cls.oauth2_authorization_type)() + + @property + def oauth2_authorization_type(cls): + return cls._oauth2_authorization_type + + @oauth2_authorization_type.setter + def oauth2_authorization_type(cls, value): + if value in ['legacy_app_flow', 'auth_code_grant', 'client_credentials_grant', 'backend_app_flow', 'web_application_flow', 'implicit_grant_flow']: + cls._oauth2_authorization_type = value + cls.__set_initial_property_values() + + @property + def client_id(cls): + return cls._client_id + + @client_id.setter + def client_id(cls, value): + cls._client_id = value + + @property + def client_secret(cls): + return cls._client_secret + + @client_secret.setter + def client_secret(cls, value): + cls._client_secret = value + + @property + def tenant_id(cls): + return cls._tenant_id + + @tenant_id.setter + def tenant_id(cls, value): + cls._tenant_id = value + + @property + def access_token(cls): + return cls._access_token + + @access_token.setter + def access_token(cls, value): + cls._access_token = value + + @property + def redirect_uri(cls): + return cls._redirect_uri + + @redirect_uri.setter + def redirect_uri(cls, value): + cls._redirect_uri = value + + @property + def auth_header(cls): + cls.__set_initial_property_values() + if cls.access_token: + cls._auth_header.update({ + 'Authorization': 'Bearer {}'.format(cls.access_token) + }) + return cls._auth_header + + @auth_header.setter + def auth_header(cls, value): + if value: + cls._auth_header = { + 'X-AnchorMailbox': value + } + elif cls.impersonate_as is not '': + cls._auth_header = { + 'X-AnchorMailbox': cls.impersonate_as + } + else: + cls._auth_header = {} @property def impersonate_as(cls): @@ -81,8 +167,15 @@ def domain(cls, value): class Authentication(object, metaclass=AuthenticationProperties): + _auth_header = {} + _oauth2_authorization_type = None + _client_id = None + _client_secret = None + _tenant_id = None + _access_token = None _impersonate_as = None _credentials = tuple() _exchange_versions = [] _endpoints = [] _domain = None + _redirect_uri = 'https://google.com' From a8faf85aa4e5936f36ee9235649cc57ec7fa0e5a Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 4 Jun 2021 22:04:23 -0500 Subject: [PATCH 38/72] Modified imports --- pyews/__init__.py | 2 +- pyews/core/__init__.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyews/__init__.py b/pyews/__init__.py index 7f574a6..2b2d460 100644 --- a/pyews/__init__.py +++ b/pyews/__init__.py @@ -1,4 +1,4 @@ from .ews import EWS -from .core import Core, ExchangeVersion, Authentication, Endpoints +from .core import Core, ExchangeVersion, Authentication, Endpoints, OAuth2Connector from .endpoint import * from .service import Autodiscover, Operation diff --git a/pyews/core/__init__.py b/pyews/core/__init__.py index f975bd5..4606a68 100644 --- a/pyews/core/__init__.py +++ b/pyews/core/__init__.py @@ -1,4 +1,5 @@ from .authentication import Authentication from .core import Core from .endpoints import Endpoints -from .exchangeversion import ExchangeVersion \ No newline at end of file +from .exchangeversion import ExchangeVersion +from .oauthconnector import OAuth2Connector \ No newline at end of file From cdee28348986f7455857b68890ec175aa1eb5dbf Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 4 Jun 2021 22:06:05 -0500 Subject: [PATCH 39/72] Adding CI for tests --- .github/workflows/pyews.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/pyews.yml diff --git a/.github/workflows/pyews.yml b/.github/workflows/pyews.yml new file mode 100644 index 0000000..1b2d96c --- /dev/null +++ b/.github/workflows/pyews.yml @@ -0,0 +1,28 @@ +name: Testing py-ews + +on: + push + +jobs: + build: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + python-version: [3.6, 3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -U -r requirements.txt + pip install -U -r test-requirements.txt + - name: Run Tests + run: | + python -m pytest \ No newline at end of file From 9b9a41fa3ebc6c372fd7ffeb8c9257e8d86be35a Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 4 Jun 2021 22:07:29 -0500 Subject: [PATCH 40/72] Adding test requirements --- test-requirements.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 test-requirements.txt diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..55b033e --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +pytest \ No newline at end of file From 5c034eb0a8f04d87fbb841003f35cbdc71193ecd Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Mon, 14 Jun 2021 16:59:52 -0500 Subject: [PATCH 41/72] Updated tests --- tests/test_authentication.py | 10 +++++----- tests/test_ews.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 532d05f..a4f8524 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -4,7 +4,7 @@ def test_authentication_init(): assert isinstance(Authentication.credentials, tuple) assert isinstance(Authentication.exchange_versions, list) assert Authentication.exchange_versions == ExchangeVersion.EXCHANGE_VERSIONS - assert Authentication.endpoints == Endpoints('company.com').get() + assert Authentication.ews_url == Endpoints('company.com').get() assert Authentication.domain == 'company.com' assert Authentication.impersonate_as == '' @@ -14,10 +14,10 @@ def test_setting_authentication_details_directly(): assert Authentication.credentials == ('user@company.com','mypassword') Authentication.exchange_versions = 'Exchange2015' assert Authentication.exchange_versions == ['Exchange2015'] - Authentication.endpoints = 'https://outlook.office365.com/EWS/Exchange.asmx' - assert Authentication.endpoints == ['https://outlook.office365.com/EWS/Exchange.asmx'] - Authentication.endpoints = ['https://outlook.office365.com/EWS/Exchange.asmx','https://outlook.office365.com/autodiscover/autodiscover.svc'] - assert Authentication.endpoints == ['https://outlook.office365.com/EWS/Exchange.asmx', 'https://outlook.office365.com/autodiscover/autodiscover.svc'] + Authentication.ews_url = 'https://outlook.office365.com/EWS/Exchange.asmx' + assert Authentication.ews_url == ['https://outlook.office365.com/EWS/Exchange.asmx'] + Authentication.ews_url = ['https://outlook.office365.com/EWS/Exchange.asmx','https://outlook.office365.com/autodiscover/autodiscover.svc'] + assert Authentication.ews_url == ['https://outlook.office365.com/EWS/Exchange.asmx', 'https://outlook.office365.com/autodiscover/autodiscover.svc'] Authentication.domain = 'testcompany.com' assert Authentication.domain == 'testcompany.com' Authentication.domain = 'first.last@testcompany.com' diff --git a/tests/test_ews.py b/tests/test_ews.py index d384dd0..ae49c17 100644 --- a/tests/test_ews.py +++ b/tests/test_ews.py @@ -8,6 +8,6 @@ def test_instantiation_of_ews_interface(pyews_ews_interface): from pyews import Authentication assert Authentication.credentials == ('username@company.com','mypassword1') assert Authentication.domain == 'company.com' - assert isinstance(Authentication.endpoints, list) + assert isinstance(Authentication.ews_url, list) assert isinstance(Authentication.exchange_versions, list) assert Authentication.impersonate_as is '' From dbde0d01da49de17a9f0a5ca616f788035788884 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Mon, 14 Jun 2021 17:00:07 -0500 Subject: [PATCH 42/72] Modified authentication from endpoints to ews_url --- pyews/core/authentication.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pyews/core/authentication.py b/pyews/core/authentication.py index 1c46a0c..e744f57 100644 --- a/pyews/core/authentication.py +++ b/pyews/core/authentication.py @@ -13,7 +13,7 @@ def __set_initial_property_values(cls): cls.auth_header = cls._credentials[0] else: cls.auth_header = None - cls.endpoints = None + cls.ews_url = None cls.exchange_versions = None if cls.tenant_id and cls.client_id and cls.client_secret: if not cls.oauth2_authorization_type: @@ -135,18 +135,18 @@ def exchange_versions(cls, value): cls._exchange_versions = value @property - def endpoints(cls): - return cls._endpoints + def ews_url(cls): + return cls._ews_url - @endpoints.setter - def endpoints(cls, value): + @ews_url.setter + def ews_url(cls, value): if not value: from .endpoints import Endpoints - cls._endpoints = Endpoints(cls._domain).get() + cls._ews_url = Endpoints(cls._domain).get() elif not isinstance(value, list): - cls._endpoints = [value] + cls._ews_url = [value] else: - cls._endpoints = value + cls._ews_url = value @property def domain(cls): @@ -176,6 +176,6 @@ class Authentication(object, metaclass=AuthenticationProperties): _impersonate_as = None _credentials = tuple() _exchange_versions = [] - _endpoints = [] + _ews_url = [] _domain = None _redirect_uri = 'https://google.com' From ad5631ecd436b781660cc3473b87470466097def Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Mon, 14 Jun 2021 17:00:19 -0500 Subject: [PATCH 43/72] updated base to ew_url --- pyews/service/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyews/service/base.py b/pyews/service/base.py index 11d08d0..1c64e5b 100644 --- a/pyews/service/base.py +++ b/pyews/service/base.py @@ -99,7 +99,7 @@ def run(self): setattr(self, item, getattr(Authentication, item)) for version in Authentication.exchange_versions: - for endpoint in Authentication.endpoints: + for endpoint in Authentication.ews_url: if self.__class__.__base__.__name__ == 'Operation' and 'autodiscover' in endpoint: self.__logger.debug('{} == Operation so skipping endpoint {}'.format(self.__class__.__base__.__name__, endpoint)) continue From 09e69dd33bc05fa4fa3085d24fcca41624a96d0b Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Mon, 14 Jun 2021 17:00:29 -0500 Subject: [PATCH 44/72] Updated documentation --- docs/core/authentication.md | 2 +- docs/index.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/core/authentication.md b/docs/core/authentication.md index 7d0a296..3a62001 100644 --- a/docs/core/authentication.md +++ b/docs/core/authentication.md @@ -2,7 +2,7 @@ The Authentication class is used to configure all communications with Exchange. Additionally, this class contains settings which are used when generating and making SOAP requests. -This class defines the authenication credentials, endpoints to attempt, exchange versions, impersonation details, and more. +This class defines the authenication credentials, ews_url(s) to attempt, exchange versions, impersonation details, and more. ```eval_rst .. autoclass:: pyews.core.authentication.Authentication diff --git a/docs/index.md b/docs/index.md index bbee8bd..3454660 100644 --- a/docs/index.md +++ b/docs/index.md @@ -97,7 +97,6 @@ ews = EWS( If you would like to specify a specific version of Exchange to use, you can provide that using the `exchange_version` parameter. By default `pyews` will attempt all Exchange versions as well as multiple static and generated EWS URLs. - Finally, if you would like to `impersonate_as` a specific user you must provide their primary SMTP address when instantiating the `EWS` class object: @@ -111,6 +110,10 @@ ews = EWS( ) ``` +### Exchange Search Multi-Threading + +You can also specify `multi_threading=True` and when you search mailboxes we will use multi-threading to perform the search. + ## Using Provided Methods Once you have instantiated the EWS class with your credentials, you will have access to pre-exposed methods for each endpoint. These methods are: From efb892d2dd936ac5b9b67e0cc5e145c118024ee8 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Mon, 14 Jun 2021 17:00:47 -0500 Subject: [PATCH 45/72] Updated ews class with ews_url instead of endpoints --- pyews/ews.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pyews/ews.py b/pyews/ews.py index 08c1b7c..5282ba9 100644 --- a/pyews/ews.py +++ b/pyews/ews.py @@ -1,3 +1,4 @@ +import os from concurrent.futures import ThreadPoolExecutor, as_completed from .core import Authentication @@ -6,9 +7,9 @@ class EWS: - def __init__(self, username, password, endpoints=None, exchange_version=None, impersonate_as=None, multi_threading=False): + def __init__(self, username, password, ews_url=None, exchange_version=None, impersonate_as=None, multi_threading=False): Authentication.credentials = (username, password) - Authentication.endpoints = endpoints + Authentication.ews_url = ews_url Authentication.exchange_versions = exchange_version Authentication.impersonate_as = impersonate_as self.multi_threading = multi_threading @@ -32,7 +33,7 @@ def resolve_names(self, user=None): def __execute_multithreaded_search(self, query, reference_id, scope): return SearchMailboxes(query=query, reference_id=reference_id, search_scope=scope).run() - def execute_ews_search(self, query, reference_id, search_scope='All', thread_count=10): + def execute_ews_search(self, query, reference_id, search_scope='All', thread_count=os.cpu_count()*5): response = [] return_list = [] if self.multi_threading: @@ -109,7 +110,7 @@ def delete_item(self, item_id, delete_type='MoveToDeletedItems'): for item in item_id: deleted_item = DeleteItem(item, delete_type=delete_type).run() - def search_and_delete_message(self, query, thread_count=10, what_if=False): + def search_and_delete_message(self, query, thread_count=os.cpu_count()*5, what_if=False): reference_id_list = [] for mailbox in self.get_searchable_mailboxes(): reference_id_list.append(mailbox.get('reference_id')) From ef070d1ca15fe606cb21427e80ddd81fda38a12b Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 09:36:15 -0500 Subject: [PATCH 46/72] Adding OAuth2 Authentication support #9 --- docs/core/oauth2.md | 97 +++++++++++++++++++ pyews/core/authentication.py | 9 ++ .../{oauthconnector.py => oauth2connector.py} | 40 ++------ 3 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 docs/core/oauth2.md rename pyews/core/{oauthconnector.py => oauth2connector.py} (85%) diff --git a/docs/core/oauth2.md b/docs/core/oauth2.md new file mode 100644 index 0000000..30405e6 --- /dev/null +++ b/docs/core/oauth2.md @@ -0,0 +1,97 @@ +# OAuth2 Connector + +`py-ews` now allows the user to authenticate using OAuth2. You can authenticate with OAuth2 using multiple different grant flow types. Below are the list of authentication methods which can be used within the py-ews OAuth2 authentication: + +* legacy_app_flow +* auth_code_grant +* client_credentials_grant +* backend_app_flow +* web_application_flow +* implicit_grant_flow + +The `OAuth2Connector` class also supports both version 1 and 2 of Microsoft's OAuth2 authentication schema. By default, `py-ews` will attempt to use both versions before failing. + +You can set the details around OAuth2 authentication using the `Authentication` class. At a minimum you must provide values for the following properties on the `Authentication` object: + +* oauth2_authorization_type (one of the values above) +* client_id +* client_secret +* tenant_id + +Additional properties include: + +* access_token +* redirect_uri +* oauth2_scope +* username +* password +* resource + +## Auth Code Grant (Interactive) + +The `auth_code_grant` authorization type is the most common and will suffice for most situations. This method requires the following property values: + +* client_id +* client_secret +* tenant_id +* redirect_uri +* oauth2_scope + +Once you choose this method you will be prompted to visit a provided URL and then copy the response URL back into the console to generate your required `access_token`. + +## Client Credentials Grant (Non-Interactive) + +The `client_credentials_grant` authorization type is the second most common and will also suffice for most situations. This method requires the following property values: + +* client_id +* client_secret +* tenant_id + +Once you choose this method you will NOT be prompted. This method is considered a Dameon or non-interactive authentication. + +## Implict Grant Flow (Interactive) + +The `implicit_grant_flow` authorization requires the following property values: + +* client_id +* tenant_id +* redirect_uri + +Once you choose this method you will be prompted to visit a provided URL and then copy the response URL back into the console to generate your required `access_token`. + +## Web Application Flow (Non-Interactive) + +The `web_application_flow` authorization requires the following property values: + +* client_id +* client_secret +* tenant_id +* redirect_uri + +## Legacy App Flow (Non-Interactive) + +The `legacy_app_flow` authorization requires the following property values: + +* client_id +* client_secret +* tenant_id +* redirect_uri +* username +* password +* scope + +## Backend App Flow (Non-Interactive) + +The `backend_app_flow` authorization requires the following property values: + +* client_id +* client_secret +* tenant_id +* scope or resource + + +```eval_rst +.. autoclass:: pyews.core.oauth2connector.OAuth2Connector + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/pyews/core/authentication.py b/pyews/core/authentication.py index e744f57..d41b05c 100644 --- a/pyews/core/authentication.py +++ b/pyews/core/authentication.py @@ -74,6 +74,14 @@ def redirect_uri(cls): def redirect_uri(cls, value): cls._redirect_uri = value + @property + def oauth2_scope(cls): + return cls._oauth2_scope + + @oauth2_scope.setter + def oauth2_scope(cls, value): + cls._oauth2_scope = value + @property def auth_header(cls): cls.__set_initial_property_values() @@ -169,6 +177,7 @@ class Authentication(object, metaclass=AuthenticationProperties): _auth_header = {} _oauth2_authorization_type = None + _oauth2_scope = None _client_id = None _client_secret = None _tenant_id = None diff --git a/pyews/core/oauthconnector.py b/pyews/core/oauth2connector.py similarity index 85% rename from pyews/core/oauthconnector.py rename to pyews/core/oauth2connector.py index c13c77c..8370f38 100644 --- a/pyews/core/oauthconnector.py +++ b/pyews/core/oauth2connector.py @@ -1,22 +1,13 @@ from oauthlib.oauth2.rfc6749.errors import InvalidGrantError -from xmltodict import parse -import requests, pendulum -import json +import requests from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse - from oauthlib.oauth2 import LegacyApplicationClient from oauthlib.oauth2 import BackendApplicationClient from requests_oauthlib import OAuth2Session -#__AUTHORITY_URL__ = 'https://login.microsoftonline.com/{{tenant}}' -#__TOKEN_ENDPOINT__ = '/oauth2/v2.0/token' -#__API_VERSION__ = 'v1.0' -#__BASE_URL__ = 'https://graph.microsoft.com' - - class OAuth2Connector: - """GraphConnector is the main authentication mechanism for Microsoft Graph OAuth2 Authentication + """OAuth2Connector is the main authentication mechanism for Microsoft Graph OAuth2 Authentication """ AUTH_MAP = { 'v1': { @@ -32,7 +23,7 @@ class OAuth2Connector: } def __init__(self, endpoint_version='v1'): - """GraphConnector is the base (parent) class of both Search and Delete classes. It is used to perform either delegated authentication flows + """OAuth2Connector is the base (parent) class of both Search and Delete classes. It is used to perform either delegated authentication flows like: (Single-Page, Web Apps, Mobile & Native Apps - Grant Auth Flow) or you can use it in the application authentication auth flows like: (Client Credentials Grant Auth Flow) @@ -61,30 +52,19 @@ def __init__(self, endpoint_version='v1'): self.resource = self.AUTH_MAP.get(endpoint_version).get('resource') else: self.resource = None - if endpoint_version == 'v2': - self.scope = [self.AUTH_MAP.get(endpoint_version).get('scope')] + if not Authentication.oauth2_scope: + if endpoint_version == 'v2': + self.scope = [self.AUTH_MAP.get(endpoint_version).get('scope')] + else: + self.scope = None else: - self.scope = None + self.scope = Authentication.oauth2_scope self.session = requests.Session() self.session.headers = { 'Content-Type': 'application/x-www-form-urlencoded' } self.session.verify = self.verify self.expiration = None - #if not self.access_token: - # if self.client_secret and self.client_id and self.tenant_id and self.username and self.password: - # if self.scope or self.resource: - # self.legacy_app_flow() - # if self.client_secret and self.client_id and self.tenant_id and self.redirect_uri and self.scope: - # self.auth_code_grant() - # elif self.client_secret and self.client_id and self.tenant_id: - # if self.scope or self.resource: - # self.client_credentials_grant() - # self.backend_app_flow() - # else: - # self.web_application_flow() - # else: - # self.implicit_grant_flow() def __prompt_user(self, url, full_response=False): print('Please go here and authorize: ', url) @@ -168,7 +148,7 @@ def implicit_grant_flow(self): def web_application_flow(self): oauth = OAuth2Session( client_id=self.client_id, - redirect_uri='http://google.com', + redirect_uri=self.redirect_uri, scope=self.scope ) authorization_url, state = oauth.authorization_url(self.authorize_url) From 0166522f9bc9ad572cb827ec923d9926290f8a36 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 09:38:37 -0500 Subject: [PATCH 47/72] Fixed import issue with rename of class --- pyews/core/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyews/core/authentication.py b/pyews/core/authentication.py index d41b05c..e9bfadb 100644 --- a/pyews/core/authentication.py +++ b/pyews/core/authentication.py @@ -1,7 +1,7 @@ from os import access from .exchangeversion import ExchangeVersion from .core import Core -from .oauthconnector import OAuth2Connector +from .oauth2connector import OAuth2Connector class AuthenticationProperties(type): From c38ab8e56ea0cd11019a8175ae508fc0f7176c68 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 09:41:02 -0500 Subject: [PATCH 48/72] Updating requirements --- requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b0394eb..6ac611e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ lxml==4.5.1 pyyaml==5.4.1 xmltodict==0.12.0 m2r==0.2.1 -fire==0.3.1 \ No newline at end of file +fire==0.3.1 +oauthlib==3.1.0 +requests-oauthlib==1.3.0 \ No newline at end of file From e7a228bf800b755ecfaed00a8fdd54d4b3b2096b Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 09:43:07 -0500 Subject: [PATCH 49/72] Updating if statements --- pyews/core/authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyews/core/authentication.py b/pyews/core/authentication.py index e9bfadb..74b811b 100644 --- a/pyews/core/authentication.py +++ b/pyews/core/authentication.py @@ -9,7 +9,7 @@ class AuthenticationProperties(type): def __set_initial_property_values(cls): if isinstance(cls._credentials, tuple): cls.domain = cls._credentials[0] - if cls.impersonate_as is not '': + if cls.impersonate_as != '': cls.auth_header = cls._credentials[0] else: cls.auth_header = None @@ -97,7 +97,7 @@ def auth_header(cls, value): cls._auth_header = { 'X-AnchorMailbox': value } - elif cls.impersonate_as is not '': + elif cls.impersonate_as != '': cls._auth_header = { 'X-AnchorMailbox': cls.impersonate_as } From 242daeb5bda2e16afe7a974222e557214f3e938b Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 09:52:08 -0500 Subject: [PATCH 50/72] Updating github action to install lxml --- .github/workflows/pyews.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pyews.yml b/.github/workflows/pyews.yml index 1b2d96c..510f866 100644 --- a/.github/workflows/pyews.yml +++ b/.github/workflows/pyews.yml @@ -18,6 +18,11 @@ jobs: uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} + # install dependencies on Ubuntu + - if: matrix.os == 'ubuntu-latest' + name: Install build dependencies + run: | + sudo apt-get install libxml2 libxslt1.1 libxml2-dev libxslt1-dev python-libxml2 python-libxslt1 - name: Install dependencies run: | python -m pip install --upgrade pip From 8fde20d4772ff46263e194b43b4c0a9ea7075bb9 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 09:54:12 -0500 Subject: [PATCH 51/72] Updating action --- .github/workflows/pyews.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pyews.yml b/.github/workflows/pyews.yml index 510f866..0ac796b 100644 --- a/.github/workflows/pyews.yml +++ b/.github/workflows/pyews.yml @@ -22,7 +22,7 @@ jobs: - if: matrix.os == 'ubuntu-latest' name: Install build dependencies run: | - sudo apt-get install libxml2 libxslt1.1 libxml2-dev libxslt1-dev python-libxml2 python-libxslt1 + sudo apt-get install libxml2-dev libxslt-dev python-dev - name: Install dependencies run: | python -m pip install --upgrade pip From b2f86ea6d7563d41feccebdbe30f0ad8c81bdda3 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 10:01:29 -0500 Subject: [PATCH 52/72] Fixing imports in init --- pyews/core/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyews/core/__init__.py b/pyews/core/__init__.py index 4606a68..8d2aaef 100644 --- a/pyews/core/__init__.py +++ b/pyews/core/__init__.py @@ -2,4 +2,4 @@ from .core import Core from .endpoints import Endpoints from .exchangeversion import ExchangeVersion -from .oauthconnector import OAuth2Connector \ No newline at end of file +from .oauth2connector import OAuth2Connector \ No newline at end of file From 9f7216d2723d41e38447cf4a1e4508b6cab602a8 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 10:01:41 -0500 Subject: [PATCH 53/72] Adding oauth2 support in ews class interface --- pyews/ews.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyews/ews.py b/pyews/ews.py index 5282ba9..da2982c 100644 --- a/pyews/ews.py +++ b/pyews/ews.py @@ -7,11 +7,19 @@ class EWS: - def __init__(self, username, password, ews_url=None, exchange_version=None, impersonate_as=None, multi_threading=False): + def __init__(self, + username, password, ews_url=None, exchange_version=None, impersonate_as=None, multi_threading=False, + tenant_id=None, client_id=None, client_secret=None, oauth2_authorization_type='auth_code_grant', redirect_uri=None, oauth2_scope=None): Authentication.credentials = (username, password) Authentication.ews_url = ews_url Authentication.exchange_versions = exchange_version Authentication.impersonate_as = impersonate_as + Authentication.tenant_id = tenant_id + Authentication.client_id = client_id + Authentication.client_secret = client_secret + Authentication.oauth2_authorization_type = oauth2_authorization_type + Authentication.redirect_uri = redirect_uri + Authentication.oauth2_scope = oauth2_scope self.multi_threading = multi_threading def chunk(self, items, n): From 0e234e26221b76db4b33c40ba6a1824deb6c3be3 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 10:20:04 -0500 Subject: [PATCH 54/72] Updating actions --- .github/workflows/pyews.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pyews.yml b/.github/workflows/pyews.yml index 0ac796b..62c684a 100644 --- a/.github/workflows/pyews.yml +++ b/.github/workflows/pyews.yml @@ -30,4 +30,6 @@ jobs: pip install -U -r test-requirements.txt - name: Run Tests run: | + pip install -U -r requirements.txt + pip install -U -r test-requirements.txt python -m pytest \ No newline at end of file From d83010a442c46eefd4fbc54d23444a687c52197e Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 10:35:31 -0500 Subject: [PATCH 55/72] changing to bs4 as requirement --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6ac611e..3aca43a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ requests==2.25.1 -beautifulsoup4==4.9.3 +bs4==4.9.3 lxml==4.5.1 pyyaml==5.4.1 xmltodict==0.12.0 From e2e7e5167d402f6e031f1993393c0fc4da055b17 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 10:37:22 -0500 Subject: [PATCH 56/72] updating version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3aca43a..44326ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ requests==2.25.1 -bs4==4.9.3 +bs4==0.0.1 lxml==4.5.1 pyyaml==5.4.1 xmltodict==0.12.0 From 033f25e0f8573fe9d122d5dce6ed18994dcaa436 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 10:48:24 -0500 Subject: [PATCH 57/72] updating lxml version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 44326ba..6616815 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ requests==2.25.1 bs4==0.0.1 -lxml==4.5.1 +lxml==4.6.3 pyyaml==5.4.1 xmltodict==0.12.0 m2r==0.2.1 From ac1e91df6eea8e1076d3fb059dacda98f20b0af2 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 15 Jun 2021 12:48:32 -0500 Subject: [PATCH 58/72] Bumped minor version of py-ews-dev --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2bb21e8..b7fb4ef 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ def parse_requirements(requirement_file): setup( name='py-ews-dev', - version='3.0.2', + version='3.1.0', packages=find_packages(exclude=['tests*']), license='MIT', description='A Python package to interact with both on-premises and Office 365 Exchange Web Services', From 59b1c7e64e800aeda74276feedb1889d43d3c9f0 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:34:25 -0500 Subject: [PATCH 59/72] Adding searchfilter class and documentation --- docs/utils/searchfilter.md | 10 +++ pyews/__init__.py | 1 + pyews/utils/searchfilter.py | 152 ++++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 docs/utils/searchfilter.md create mode 100644 pyews/utils/searchfilter.py diff --git a/docs/utils/searchfilter.md b/docs/utils/searchfilter.md new file mode 100644 index 0000000..2de885c --- /dev/null +++ b/docs/utils/searchfilter.md @@ -0,0 +1,10 @@ +# SearchFilter + +This documentation provides details about the `SearchFilter` class which generates XML (SOAP) based on a common language search criteria. + + +```eval_rst +.. automodule:: pyews.utils.searchfilter.SearchFilter + :members: + :undoc-members: +``` diff --git a/pyews/__init__.py b/pyews/__init__.py index 2b2d460..bdcbbfc 100644 --- a/pyews/__init__.py +++ b/pyews/__init__.py @@ -2,3 +2,4 @@ from .core import Core, ExchangeVersion, Authentication, Endpoints, OAuth2Connector from .endpoint import * from .service import Autodiscover, Operation +from .utils.searchfilter import SearchFilter diff --git a/pyews/utils/searchfilter.py b/pyews/utils/searchfilter.py new file mode 100644 index 0000000..638a6b0 --- /dev/null +++ b/pyews/utils/searchfilter.py @@ -0,0 +1,152 @@ +from ..service.base import Base, etree +from ..utils.exceptions import UknownValueError +from ..utils.attributes import FIELD_URI_MAP, SEARCH_FILTERS + + +class SearchFilter: + """SearchFilter is used to generate a search filter XML body for + the FindItem EWS Operation + """ + + FIELD_NAMES = FIELD_URI_MAP + EXPRESSIONS = list(SEARCH_FILTERS.keys()) + + def __init__(self, value, to_string=False): + """Pass in a string value and in return this class returns a + XML object + + Detailed Information: + Each search filter is made of several elements. These are: + Field Name - Retrieve all available Field Names by using SearchFilter.FIELD_NAMES + Expression - Retrieve all expressions by using SearchFilter.EXPRESSIONS + Value - Values are typically strings which make sens for field names + + A logic operator within the SearchFilter class is one of: + and - This will perform a boolean AND search operation between two or more search expressions + or - This will perform a logical OR search operation between two or more search expressions + not - This will perform a search expression that negates a search expression + + + Examples: + SearchFilter('Body contains Hello World') + SearchFilter('Subject contains phishing and IsRead IsEqualTo true') + SearchFilter('Subject contains phishing and IsRead IsEqualTo true or Subject contains microsoft', to_string=True) + Args: + value ([str]): A query string + """ + self.to_string = to_string + self.__parent = Base.T_NAMESPACE.Restriction() + self.__build_return_object(value) + if to_string: + self._value = str(etree.tostring(self.__parent).decode("utf-8")) + else: + self._value = self.__parent + + def __new__(cls, value, to_string=False): + instance = super(SearchFilter, cls).__new__(cls) + if hasattr(cls, '__init__'): + instance.__init__(value, to_string=to_string) + return instance._value + + @property + def search_string(self): + if self.to_string: + return str(etree.tostring(self.__parent).decode("utf-8")) + return self.__parent + + def __build_return_object(self, value): + if 'and' in value: + object_list = [] + for item in value.split('and'): + obj = self.__evaluate(item) + if obj is not None: + object_list.append(obj) + self.__parent.append(Base.T_NAMESPACE.And(*object_list)) + if 'or' in value: + object_list = [] + for item in value.split('or'): + obj = self.__evaluate(item) + if obj is not None: + object_list.append(obj) + self.__parent.append(Base.T_NAMESPACE.Or(*object_list)) + else: + self.__parent.append(self.__evaluate(value)) + self.__parent + + def __build_search_filter(self, search_filter, field, field_value): + if search_filter == 'Contains': + return Base.T_NAMESPACE.Contains( + Base.T_NAMESPACE.FieldURI(FieldURI=field), + Base.T_NAMESPACE.FieldURIOrConstant( + Base.T_NAMESPACE.Constant(Value=field_value) + ), + # Base.T_NAMESPACE.Constant(Value=field_value), + ContainmentMode='Substring', + ContainmentComparison='IgnoreCase' + ) + elif search_filter == 'Excludes': + return Base.T_NAMESPACE.Excludes( + Base.T_NAMESPACE.ExtendedFieldURI( + PropertySetId="aa3df801-4fc7-401f-bbc1-7c93d6498c2e", + PropertyName="ItemIndex", + PropertyType="Integer" + ), + Base.T_NAMESPACE.Bitmask(Value=field_value) + ) + elif search_filter == 'Exists': + return Base.T_NAMESPACE.Exists( + Base.T_NAMESPACE.ExtendedFieldURI( + PropertySetId="aa3df801-4fc7-401f-bbc1-7c93d6498c2e", + PropertyName="ItemIndex", + PropertyType="Integer" + ), + ) + elif search_filter == 'IsEqualTo': + return Base.T_NAMESPACE.IsEqualTo( + Base.T_NAMESPACE.FieldURI(FieldURI=field), + Base.T_NAMESPACE.FieldURIOrConstant( + Base.T_NAMESPACE.Constant(Value=field_value) + ) + ) + elif search_filter == 'IsNotEqualTo': + return Base.T_NAMESPACE.IsNotEqualTo( + Base.T_NAMESPACE.ExtendedFieldURI( + PropertySetId="aa3df801-4fc7-401f-bbc1-7c93d6498c2e", + PropertyName="ItemIndex", + PropertyType="Integer" + ), + Base.T_NAMESPACE.FieldURIOrConstant( + Base.T_NAMESPACE.FieldURI(FieldURI=field) + ) + ) + + def __to_title_case(self, value): + return ''.join([x for x in value.title() if not x.isspace()]) + + def __evaluate(self, value): + search_filter = None + field = None + field_value = None + temp_value = value + for key,val in SEARCH_FILTERS.items(): + if key in value: + search_filter = key + temp_value = value.split(key) + field = temp_value[0].strip() + field_value = temp_value[1].strip() + elif key.lower() in value: + search_filter = key + temp_value = value.split(key.lower()) + field = temp_value[0].strip() + field_value = temp_value[1].strip() + if field: + for key,val in FIELD_URI_MAP.items(): + if field in val: + field = f"{key}:{field}" + elif self.__to_title_case(field) in val: + field = f"{key}:{self.__to_title_case(field)}" + if ' or ' in field_value: + field_value = field_value.split(' or ')[0] + elif ' and ' in field_value: + field_value = field_value.split(' and ')[0] + return self.__build_search_filter(search_filter, field, field_value) From 0b53aaab079340c8f53f15d35451ae1ba014e1c8 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:37:47 -0500 Subject: [PATCH 60/72] Adding CreateFolder endpoint --- docs/endpoint/createfolder.md | 11 ++++ pyews/endpoint/__init__.py | 3 +- pyews/endpoint/createfolder.py | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 docs/endpoint/createfolder.md create mode 100644 pyews/endpoint/createfolder.py diff --git a/docs/endpoint/createfolder.md b/docs/endpoint/createfolder.md new file mode 100644 index 0000000..6f08bd7 --- /dev/null +++ b/docs/endpoint/createfolder.md @@ -0,0 +1,11 @@ +# CreateFolder + +This documentation provides details about the CreateFolder class within the `pyews` package. + +This class is used to create folders (specifically focused on search folders) in the specified user mailbox. + +```eval_rst +.. autoclass:: pyews.endpoint.createfolder.CreateFolder + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/pyews/endpoint/__init__.py b/pyews/endpoint/__init__.py index 8e2a210..94e47a8 100644 --- a/pyews/endpoint/__init__.py +++ b/pyews/endpoint/__init__.py @@ -13,4 +13,5 @@ from .syncfolderitems import SyncFolderItems from .createitem import CreateItem from .getserviceconfiguration import GetServiceConfiguration -from .getdomainsettings import GetDomainSettings \ No newline at end of file +from .getdomainsettings import GetDomainSettings +from .createfolder import CreateFolder diff --git a/pyews/endpoint/createfolder.py b/pyews/endpoint/createfolder.py new file mode 100644 index 0000000..41f4345 --- /dev/null +++ b/pyews/endpoint/createfolder.py @@ -0,0 +1,97 @@ +from ..service import Operation +from lxml import etree +from ..utils.exceptions import UknownValueError +from ..utils.attributes import FOLDER_LIST, FIELD_URI_MAP, TRAVERSAL_LIST +from ..utils.searchfilter import SearchFilter + + +class CreateFolder(Operation): + """FindItem EWS Operation retrieves details about an item. + """ + + RESULTS_KEY = 'CreateFolderResponseMessage' + BASE_SHAPES = [ + 'IdOnly', + 'Default', + 'AllProperties' + ] + BODY_TYPES = [ + 'Best', + 'HTML', + 'Text' + ] + SEARCH_FILTERS = [ + 'Contains', + 'Excludes', + 'Exists', + 'IsEqualTo', + 'IsNotEqualTo', + 'IsGreaterThan', + 'IsGreaterThanOrEqualTo', + 'IsLessThan', + 'IsLessThanOrEqualTo' + ] + + def __init__(self, search_string, search_folder=True, display_name='Search Folder', search_all_folders=True, base_folder='inbox', traversal='Deep'): + """Creates a Folder. Default behavior is to create a Search Folder (eDiscovery Search Folder) + + search_string: + Detailed Information: + Each search filter is made of several elements. These are: + Field Name - Retrieve all available Field Names by using SearchFilter.FIELD_NAMES + Expression - Retrieve all expressions by using SearchFilter.EXPRESSIONS + Value - Values are typically strings which make sens for field names + A logic operator within the SearchFilter class is one of: + and - This will perform a boolean AND search operation between two or more search expressions + or - This will perform a logical OR search operation between two or more search expressions + not - This will perform a search expression that negates a search expression + Examples: + SearchFilter('Body contains Hello World') + SearchFilter('Subject contains phishing and IsRead IsEqualTo true') + SearchFilter('Subject contains phishing and IsRead IsEqualTo true or Subject contains microsoft', to_string=True) + + Args: + search_string (str): A search string which is converted to a Search Filter + search_folder (bool, optional): Creates a Search Folder. Defaults to True. + display_name (str, optional): The display name of the search folder. Defaults to 'Search Folder'. + base_folder (str, optional): The base folder the search folder should look at. Defaults to 'inbox'. + traversal (str, optional): The traversal type. Options are Deep and Shallow. Defaults to 'Deep'. + """ + self.search_string = SearchFilter(search_string) + self.display_name = display_name + if search_folder: + self.folder_id = [self.T_NAMESPACE.DistinguishedFolderId(Id='searchfolders')] + else: + self.folder_id = self.__get_distinguished_folder_ids() + #if search_all_folders: + # self.base_folder_ids = self.__get_distinguished_folder_ids() + #else: + self.base_folder_ids = [self.T_NAMESPACE.DistinguishedFolderId(Id=base_folder)] + if traversal not in TRAVERSAL_LIST: + raise UknownValueError(provided_value=traversal, known_values=TRAVERSAL_LIST) + self.traversal = traversal + + def __get_distinguished_folder_ids(self): + return_list = [] + for item in FOLDER_LIST: + return_list.append(self.T_NAMESPACE.DistinguishedFolderId(Id=item)) + return return_list + + def soap(self): + return self.M_NAMESPACE.CreateFolder( + self.M_NAMESPACE.ParentFolderId( + *self.folder_id + ), + self.M_NAMESPACE.Folders( + self.T_NAMESPACE.SearchFolder( + self.T_NAMESPACE.DisplayName(self.display_name), + self.T_NAMESPACE.SearchParameters( + self.search_string, + self.T_NAMESPACE.BaseFolderIds( + *self.base_folder_ids + ), + Traversal=self.traversal + ) + ) + ) + ) From c629a9a6d017ddff9224d421921d8c4ab6294aa9 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:38:27 -0500 Subject: [PATCH 61/72] Adding DeleteFolder endpoint --- docs/endpoint/deletefolder.md | 11 +++++++++++ pyews/endpoint/__init__.py | 1 + pyews/endpoint/deletefolder.py | 26 ++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 docs/endpoint/deletefolder.md create mode 100644 pyews/endpoint/deletefolder.py diff --git a/docs/endpoint/deletefolder.md b/docs/endpoint/deletefolder.md new file mode 100644 index 0000000..dc0f38a --- /dev/null +++ b/docs/endpoint/deletefolder.md @@ -0,0 +1,11 @@ +# DeleteFolder + +This documentation provides details about the DeleteFolder class within the `pyews` package. + +This class is used to delete folders (specifically focused on search folders) from the specified user mailbox. + +```eval_rst +.. autoclass:: pyews.endpoint.deletefolder.DeleteFolder + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/pyews/endpoint/__init__.py b/pyews/endpoint/__init__.py index 94e47a8..0feb54b 100644 --- a/pyews/endpoint/__init__.py +++ b/pyews/endpoint/__init__.py @@ -15,3 +15,4 @@ from .getserviceconfiguration import GetServiceConfiguration from .getdomainsettings import GetDomainSettings from .createfolder import CreateFolder +from .deletefolder import DeleteFolder diff --git a/pyews/endpoint/deletefolder.py b/pyews/endpoint/deletefolder.py new file mode 100644 index 0000000..ba7d9e7 --- /dev/null +++ b/pyews/endpoint/deletefolder.py @@ -0,0 +1,26 @@ +from ..service import Operation +from ..utils.exceptions import UknownValueError +from ..utils.attributes import FOLDER_LIST, TRAVERSAL_LIST + + +class DeleteFolder(Operation): + """DeleteFolder EWS Operation attempts to delete a folder. + Specifically this class is focused on deleting search folders. + """ + + RESULTS_KEY = 'Folders' + + def __init__(self, folder_id=): + """ + """ + self.folder_id = folder_id + + def soap(self): + return self.M_NAMESPACE.DeleteFolder( + self.M_NAMESPACE.FolderIds( + self.T_NAMESPACE.FolderId( + Id=self.folder_id + ) + ), + DeleteType="MoveToDeletedItems" + ) From 9b3ae221806648c7cfdc96f91600399419544567 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:38:53 -0500 Subject: [PATCH 62/72] Adding FindFolder endpoint --- docs/endpoint/findfolder.md | 11 +++++++ pyews/endpoint/__init__.py | 1 + pyews/endpoint/findfolder.py | 56 ++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 docs/endpoint/findfolder.md create mode 100644 pyews/endpoint/findfolder.py diff --git a/docs/endpoint/findfolder.md b/docs/endpoint/findfolder.md new file mode 100644 index 0000000..b1ab55d --- /dev/null +++ b/docs/endpoint/findfolder.md @@ -0,0 +1,11 @@ +# FindFolder + +This documentation provides details about the FindFolder class within the `pyews` package. + +This class is used to find (search) for a search folder in a mailbox. + +```eval_rst +.. autoclass:: pyews.endpoint.findfolder.FindFolder + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/pyews/endpoint/__init__.py b/pyews/endpoint/__init__.py index 0feb54b..6a752ca 100644 --- a/pyews/endpoint/__init__.py +++ b/pyews/endpoint/__init__.py @@ -16,3 +16,4 @@ from .getdomainsettings import GetDomainSettings from .createfolder import CreateFolder from .deletefolder import DeleteFolder +from .findfolder import FindFolder diff --git a/pyews/endpoint/findfolder.py b/pyews/endpoint/findfolder.py new file mode 100644 index 0000000..e80ba1b --- /dev/null +++ b/pyews/endpoint/findfolder.py @@ -0,0 +1,56 @@ +from ..service import Operation +from ..utils.exceptions import UknownValueError +from ..utils.attributes import FOLDER_LIST, TRAVERSAL_LIST + + +class FindFolder(Operation): + """FindFolder EWS Operation attempts to find a folder. + Specifically this class is focused on finding search folders. + """ + + RESULTS_KEY = 'Folders' + BASE_SHAPES = [ + 'IdOnly', + 'Default', + 'AllProperties' + ] + BODY_TYPES = [ + 'Best', + 'HTML', + 'Text' + ] + SEARCH_FILTERS = [ + 'Contains', + 'Excludes', + 'Exists', + 'IsEqualTo', + 'IsNotEqualTo', + 'IsGreaterThan', + 'IsGreaterThanOrEqualTo', + 'IsLessThan', + 'IsLessThanOrEqualTo' + ] + + def __init__(self, folder_id='searchfolders', traversal='Shallow'): + """ + """ + self.folder_id = folder_id + if traversal not in TRAVERSAL_LIST: + raise UknownValueError(provided_value=traversal, known_values=TRAVERSAL_LIST) + self.traversal = traversal + + def soap(self): + return self.M_NAMESPACE.FindFolder( + self.M_NAMESPACE.FolderShape( + self.T_NAMESPACE.BaseShape('AllProperties'), + self.T_NAMESPACE.AdditionalProperties( + self.T_NAMESPACE.FieldURI(FieldURI='folder:DisplayName') + ) + ), + self.M_NAMESPACE.IndexedPageFolderView( + MaxEntriesReturned="10", Offset="0", BasePoint="Beginning" + ), + self.M_NAMESPACE.ParentFolderIds( + self.T_NAMESPACE.DistinguishedFolderId(Id="searchfolders") + ) + ) From 1efc42857fe4ff8d1276d0d9de2b9e6f0b0c879f Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:39:13 -0500 Subject: [PATCH 63/72] Adding FindItem endpoint --- docs/endpoint/finditem.md | 11 ++++++ pyews/endpoint/__init__.py | 2 + pyews/endpoint/finditem.py | 81 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 docs/endpoint/finditem.md create mode 100644 pyews/endpoint/finditem.py diff --git a/docs/endpoint/finditem.md b/docs/endpoint/finditem.md new file mode 100644 index 0000000..875ea7c --- /dev/null +++ b/docs/endpoint/finditem.md @@ -0,0 +1,11 @@ +# FindItem + +This documentation provides details about the FindItem class within the `pyews` package. + +This class is used to find (search) an item from a mailbox. + +```eval_rst +.. autoclass:: pyews.endpoint.finditem.FindItem + :members: + :undoc-members: +``` \ No newline at end of file diff --git a/pyews/endpoint/__init__.py b/pyews/endpoint/__init__.py index 6a752ca..a0e3b53 100644 --- a/pyews/endpoint/__init__.py +++ b/pyews/endpoint/__init__.py @@ -16,4 +16,6 @@ from .getdomainsettings import GetDomainSettings from .createfolder import CreateFolder from .deletefolder import DeleteFolder +from .finditem import FindItem from .findfolder import FindFolder +from .deletefolder import DeleteFolder \ No newline at end of file diff --git a/pyews/endpoint/finditem.py b/pyews/endpoint/finditem.py new file mode 100644 index 0000000..90b9f68 --- /dev/null +++ b/pyews/endpoint/finditem.py @@ -0,0 +1,81 @@ +from ..service import Operation +from ..utils.exceptions import UknownValueError +from ..utils.attributes import FOLDER_LIST, TRAVERSAL_LIST + + +class FindItem(Operation): + """FindItem EWS Operation retrieves details about an item. + """ + + RESULTS_KEY = 'Message' + BASE_SHAPES = [ + 'IdOnly', + 'Default', + 'AllProperties' + ] + BODY_TYPES = [ + 'Best', + 'HTML', + 'Text' + ] + + def __init__(self, query_string, distinguished_folder_name='inbox', base_shape='AllProperties', include_mime_content=True, body_type='Best', traversal='Shallow', reset_cache=False, return_deleted_items=True, return_highlight_terms=True): + """Retrieves results from a query string + + Args: + item_id (str): The item id you want to get information about. + change_key (str, optional): The change key of the item. Defaults to None. + base_shape (str, optional): The base shape of the returned item. Defaults to 'AllProperties'. + include_mime_content (bool, optional): Whether or not to include MIME content. Defaults to True. + body_type (str, optional): The item body type. Defaults to 'Best'. + """ + if distinguished_folder_name: + self.folder_name = [self.T_NAMESPACE.DistinguishedFolderId(Id=distinguished_folder_name)] + else: + self.folder_name = self.__get_distinguished_folder_ids() + self.query_string = query_string + self.include_mime_content = include_mime_content + if base_shape not in self.BASE_SHAPES: + raise UknownValueError(provided_value=base_shape, known_values=self.BASE_SHAPES) + self.base_shape = base_shape + if body_type not in self.BODY_TYPES: + raise UknownValueError(provided_value=body_type, known_values=self.BODY_TYPES) + self.body_type = body_type + if traversal not in TRAVERSAL_LIST: + raise UknownValueError(provided_value=traversal, known_values=TRAVERSAL_LIST) + self.traversal = traversal + + self.query_properties = {} + if reset_cache: + self.query_properties.update({ + 'ResetCache': 'true' + }) + if return_deleted_items: + self.query_properties.update({ + 'ReturnDeletedItems': 'true' + }) + if return_highlight_terms: + self.query_properties.update({ + 'ReturnHighlightTerms': 'true' + }) + + def __get_distinguished_folder_ids(self): + return_list = [] + for item in FOLDER_LIST: + return_list.append(self.T_NAMESPACE.DistinguishedFolderId(Id=item)) + return return_list + + def soap(self): + return self.M_NAMESPACE.FindItem( + self.M_NAMESPACE.ItemShape( + self.M_NAMESPACE.BaseShape(self.base_shape), + self.M_NAMESPACE.IncludeMimeContent(str(self.include_mime_content).lower()), + self.M_NAMESPACE.BodyType(self.body_type), + self.M_NAMESPACE.FilterHtmlContent('false') + ), + self.M_NAMESPACE.ParentFolderIds( + *self.folder_name + ), + self.M_NAMESPACE.QueryString(self.query_string, **self.query_properties), + Traversal=self.traversal + ) From 0fa53dd026e0380cc57e3e362c866b267d341b11 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:40:29 -0500 Subject: [PATCH 64/72] Adding traversal_list to attributes --- pyews/utils/attributes.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyews/utils/attributes.py b/pyews/utils/attributes.py index 69a70f0..bc2b6f2 100644 --- a/pyews/utils/attributes.py +++ b/pyews/utils/attributes.py @@ -1,3 +1,10 @@ +TRAVERSAL_LIST = [ + 'Deep', + 'Shallow', + 'SoftDeleted', + 'Associated' +] + FOLDER_LIST = [ 'msgfolderroot', 'calendar', From 3291649395f1ef6d2ccdafc5558adfb88c974ebc Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:40:59 -0500 Subject: [PATCH 65/72] adding search filters definition to attributes --- pyews/utils/attributes.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/pyews/utils/attributes.py b/pyews/utils/attributes.py index bc2b6f2..deae5a3 100644 --- a/pyews/utils/attributes.py +++ b/pyews/utils/attributes.py @@ -187,4 +187,39 @@ "IsResponseRequested": [ 'true' ] -} \ No newline at end of file +} + +SEARCH_FILTERS = { + 'Contains': { + 'ContainmentMode': [ + 'FullString', + 'Prefixed', + 'Substring', + 'PrefixOnWords', + 'ExactPhrase' + ], + 'ContainmentComparison': [ + 'Exact', + 'IgnoreCase', + 'IgnoreNonSpacingCharacters', + 'Loose', + 'IgnoreCaseAndNonSpacingCharacters', + 'LooseAndIgnoreCase', + 'LooseAndIgnoreNonSpace', + 'LooseAndIgnoreCaseAndIgnoreNonSpace' + ] + }, + 'Excludes': { + 'Bitmask': [] + }, + 'Exists': {}, + 'IsEqualTo': { + 'FieldURIOrConstant': [] + }, + 'IsNotEqualTo': {}, + 'IsGreaterThan': {}, + 'IsGreaterThanOrEqualTo': {}, + 'IsLessThan': {}, + 'IsLessThanOrEqualTo': {} +} + From 982a39ca5bbd80858f89aa43ad7dbb19021e2787 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:41:28 -0500 Subject: [PATCH 66/72] Adding field URI map to attributes --- pyews/utils/attributes.py | 357 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) diff --git a/pyews/utils/attributes.py b/pyews/utils/attributes.py index deae5a3..52c2f74 100644 --- a/pyews/utils/attributes.py +++ b/pyews/utils/attributes.py @@ -223,3 +223,360 @@ 'IsLessThanOrEqualTo': {} } +FIELD_URI_MAP = { + 'folder': [ + "FolderId", + "ParentFolderId", + "DisplayName", + "UnreadCount", + "TotalCount", + "ChildFolderCount", + "FolderClass", + "SearchParameters", + "ManagedFolderInformation", + "PermissionSet", + "EffectiveRights", + "SharingEffectiveRights" + ], + 'item': [ + "ItemId", + "ParentFolderId", + "ItemClass", + "MimeContent", + "Attachments", + "Subject", + "DateTimeReceived", + "Size", + "Categories", + "HasAttachments", + "Importance", + "InReplyTo", + "InternetMessageHeaders", + "IsAssociated", + "IsDraft", + "IsFromMe", + "IsResend", + "IsSubmitted", + "IsUnmodified", + "DateTimeSent", + "DateTimeCreated", + "Body", + "ResponseObjects", + "Sensitivity", + "ReminderDueBy", + "ReminderIsSet", + "ReminderNextTime", + "ReminderMinutesBeforeStart", + "DisplayTo", + "DisplayCc", + "Culture", + "EffectiveRights", + "LastModifiedName", + "LastModifiedTime", + "ConversationId", + "UniqueBody", + "Flag", + "StoreEntryId", + "InstanceKey", + "NormalizedBody", + "EntityExtractionResult", + "ArchiveTag", + "RetentionDate", + "Preview", + "NextPredictedAction", + "GroupingAction", + "PredictedActionReasons", + "IsClutter", + "RightsManagementLicenseData", + "BlockStatus", + "HasBlockedImages", + "WebClientReadFormQueryString", + "WebClientEditFormQueryString", + "TextBody", + "IconIndex", + "MimeContentUTF8" + ], + 'message': [ + "ConversationIndex", + "ConversationTopic", + "InternetMessageId", + "IsRead", + "IsResponseRequested", + "IsReadReceiptRequested", + "IsDeliveryReceiptRequested", + "ReceivedBy", + "ReceivedRepresenting", + "References", + "ReplyTo", + "From", + "Sender", + "ToRecipients", + "CcRecipients", + "BccRecipients", + "ApprovalRequestData", + "VotingInformation", + "ReminderMessageData", + ], + 'meeting': [ + "AssociatedCalendarItemId", + "IsDelegated", + "IsOutOfDate", + "HasBeenProcessed", + "ResponseType", + "ProposedStart", + "PropsedEnd", + ], + 'meetingRequest': [ + "MeetingRequestType", + "IntendedFreeBusyStatus", + "ChangeHighlights", + ], + 'calendar': [ + "Start", + "End", + "OriginalStart", + "StartWallClock", + "EndWallClock", + "StartTimeZoneId", + "EndTimeZoneId", + "IsAllDayEvent", + "LegacyFreeBusyStatus", + "Location", + "When", + "IsMeeting", + "IsCancelled", + "IsRecurring", + "MeetingRequestWasSent", + "IsResponseRequested", + "CalendarItemType", + "MyResponseType", + "Organizer", + "RequiredAttendees", + "OptionalAttendees", + "Resources", + "ConflictingMeetingCount", + "AdjacentMeetingCount", + "ConflictingMeetings", + "AdjacentMeetings", + "Duration", + "TimeZone", + "AppointmentReplyTime", + "AppointmentSequenceNumber", + "AppointmentState", + "Recurrence", + "FirstOccurrence", + "LastOccurrence", + "ModifiedOccurrences", + "DeletedOccurrences", + "MeetingTimeZone", + "ConferenceType", + "AllowNewTimeProposal", + "IsOnlineMeeting", + "MeetingWorkspaceUrl", + "NetShowUrl", + "UID", + "RecurrenceId", + "DateTimeStamp", + "StartTimeZone", + "EndTimeZone", + "JoinOnlineMeetingUrl", + "OnlineMeetingSettings", + "IsOrganizer", + ], + 'task': [ + "ActualWork", + "AssignedTime", + "BillingInformation", + "ChangeCount", + "Companies", + "CompleteDate", + "Contacts", + "DelegationState", + "Delegator", + "DueDate", + "IsAssignmentEditable", + "IsComplete", + "IsRecurring", + "IsTeamTask", + "Mileage", + "Owner", + "PercentComplete", + "Recurrence", + "StartDate", + "Status", + "StatusDescription", + "TotalWork", + ], + 'contacts': [ + "Alias", + "AssistantName", + "Birthday", + "BusinessHomePage", + "Children", + "Companies", + "CompanyName", + "CompleteName", + "ContactSource", + "Culture", + "Department", + "DisplayName", + "DirectoryId", + "DirectReports", + "EmailAddresses", + "FileAs", + "FileAsMapping", + "Generation", + "GivenName", + "ImAddresses", + "Initials", + "JobTitle", + "Manager", + "ManagerMailbox", + "MiddleName", + "Mileage", + "MSExchangeCertificate", + "Nickname", + "Notes", + "OfficeLocation", + "PhoneNumbers", + "PhoneticFullName", + "PhoneticFirstName", + "PhoneticLastName", + "Photo", + "PhysicalAddresses", + "PostalAddressIndex", + "Profession", + "SpouseName", + "Surname", + "WeddingAnniversary", + "UserSMIMECertificate", + "HasPicture", + ], + 'conversation': [ + "ConversationId", + "ConversationTopic", + "UniqueRecipients", + "GlobalUniqueRecipients", + "UniqueUnreadSenders", + "GlobalUniqueUnreadSenders", + "UniqueSenders", + "GlobalUniqueSenders", + "LastDeliveryTime", + "GlobalLastDeliveryTime", + "Categories", + "GlobalCategories", + "FlagStatus", + "GlobalFlagStatus", + "HasAttachments", + "GlobalHasAttachments", + "HasIrm", + "GlobalHasIrm", + "MessageCount", + "GlobalMessageCount", + "UnreadCount", + "GlobalUnreadCount", + "Size", + "GlobalSize", + "ItemClasses", + "GlobalItemClasses", + "Importance", + "GlobalImportance", + "ItemIds", + "GlobalItemIds", + "LastModifiedTime", + "InstanceKey", + "Preview", + "GlobalParentFolderId", + "NextPredictedAction", + "GroupingAction", + "IconIndex", + "GlobalIconIndex", + "DraftItemIds", + "HasClutter", + ], + 'persona': [ + "PersonaId", + "PersonaType", + "GivenName", + "CompanyName", + "Surname", + "DisplayName", + "EmailAddress", + "FileAs", + "HomeCity", + "CreationTime", + "RelevanceScore", + "WorkCity", + "PersonaObjectStatus", + "FileAsId", + "DisplayNamePrefix", + "YomiCompanyName", + "YomiFirstName", + "YomiLastName", + "Title", + "EmailAddresses", + "PhoneNumber", + "ImAddress", + "ImAddresses", + "ImAddresses2", + "ImAddresses3", + "FolderIds", + "Attributions", + "DisplayNames", + "Initials", + "FileAses", + "FileAsIds", + "DisplayNamePrefixes", + "GivenNames", + "MiddleNames", + "Surnames", + "Generations", + "Nicknames", + "YomiCompanyNames", + "YomiFirstNames", + "YomiLastNames", + "BusinessPhoneNumbers", + "BusinessPhoneNumbers2", + "HomePhones", + "HomePhones2", + "MobilePhones", + "MobilePhones2", + "AssistantPhoneNumbers", + "CallbackPhones", + "CarPhones", + "HomeFaxes", + "OrganizationMainPhones", + "OtherFaxes", + "OtherTelephones", + "OtherPhones2", + "Pagers", + "RadioPhones", + "TelexNumbers", + "WorkFaxes", + "Emails1", + "Emails2", + "Emails3", + "BusinessHomePages", + "School", + "PersonalHomePages", + "OfficeLocations", + "BusinessAddresses", + "HomeAddresses", + "OtherAddresses", + "Titles", + "Departments", + "CompanyNames", + "Managers", + "AssistantNames", + "Professions", + "SpouseNames", + "Hobbies", + "WeddingAnniversaries", + "Birthdays", + "Children", + "Locations", + "ExtendedProperties", + "PostalAddress", + "Bodies", + ] +} From 64039fcd1b80154d47c866ea6eb49da92054e21e Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:41:43 -0500 Subject: [PATCH 67/72] Adding known response codes to attributes --- pyews/utils/attributes.py | 532 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 532 insertions(+) diff --git a/pyews/utils/attributes.py b/pyews/utils/attributes.py index 52c2f74..dd16c04 100644 --- a/pyews/utils/attributes.py +++ b/pyews/utils/attributes.py @@ -580,3 +580,535 @@ "Bodies", ] } + +RESPONSE_CODES = [ + "NoError", + "ErrorAccessDenied", + "ErrorAccessModeSpecified", + "ErrorAccountDisabled", + "ErrorAddDelegatesFailed", + "ErrorAddressSpaceNotFound", + "ErrorADOperation", + "ErrorADSessionFilter", + "ErrorADUnavailable", + "ErrorServiceUnavailable", + "ErrorAutoDiscoverFailed", + "ErrorAffectedTaskOccurrencesRequired", + "ErrorAttachmentNestLevelLimitExceeded" , + "ErrorAttachmentSizeLimitExceeded", + "ErrorArchiveFolderPathCreation", + "ErrorArchiveMailboxNotEnabled", + "ErrorArchiveMailboxServiceDiscoveryFailed", + "ErrorAvailabilityConfigNotFound", + "ErrorBatchProcessingStopped", + "ErrorCalendarCannotMoveOrCopyOccurrence", + "ErrorCalendarCannotUpdateDeletedItem", + "ErrorCalendarCannotUseIdForOccurrenceId", + "ErrorCalendarCannotUseIdForRecurringMasterId", + "ErrorCalendarDurationIsTooLong", + "ErrorCalendarEndDateIsEarlierThanStartDate", + "ErrorCalendarFolderIsInvalidForCalendarView", + "ErrorCalendarInvalidAttributeValue", + "ErrorCalendarInvalidDayForTimeChangePattern", + "ErrorCalendarInvalidDayForWeeklyRecurrence", + "ErrorCalendarInvalidPropertyState", + "ErrorCalendarInvalidPropertyValue", + "ErrorCalendarInvalidRecurrence", + "ErrorCalendarInvalidTimeZone", + "ErrorCalendarIsCancelledForAccept", + "ErrorCalendarIsCancelledForDecline", + "ErrorCalendarIsCancelledForRemove", + "ErrorCalendarIsCancelledForTentative", + "ErrorCalendarIsDelegatedForAccept", + "ErrorCalendarIsDelegatedForDecline", + "ErrorCalendarIsDelegatedForRemove", + "ErrorCalendarIsDelegatedForTentative", + "ErrorCalendarIsNotOrganizer", + "ErrorCalendarIsOrganizerForAccept", + "ErrorCalendarIsOrganizerForDecline", + "ErrorCalendarIsOrganizerForRemove", + "ErrorCalendarIsOrganizerForTentative", + "ErrorCalendarOccurrenceIndexIsOutOfRecurrenceRange", + "ErrorCalendarOccurrenceIsDeletedFromRecurrence", + "ErrorCalendarOutOfRange", + "ErrorCalendarMeetingRequestIsOutOfDate", + "ErrorCalendarViewRangeTooBig", + "ErrorCallerIsInvalidADAccount", + "ErrorCannotAccessDeletedPublicFolder", + "ErrorCannotArchiveCalendarContactTaskFolderException", + "ErrorCannotArchiveItemsInPublicFolders", + "ErrorCannotArchiveItemsInArchiveMailbox", + "ErrorCannotCreateCalendarItemInNonCalendarFolder", + "ErrorCannotCreateContactInNonContactFolder", + "ErrorCannotCreatePostItemInNonMailFolder", + "ErrorCannotCreateTaskInNonTaskFolder", + "ErrorCannotDeleteObject", + "ErrorCannotDisableMandatoryExtension", + "ErrorCannotFindUser", + "ErrorCannotGetSourceFolderPath", + "ErrorCannotGetExternalEcpUrl", + "ErrorCannotOpenFileAttachment", + "ErrorCannotDeleteTaskOccurrence", + "ErrorCannotEmptyFolder", + "ErrorCannotSetCalendarPermissionOnNonCalendarFolder", + "ErrorCannotSetNonCalendarPermissionOnCalendarFolder", + "ErrorCannotSetPermissionUnknownEntries", + "ErrorCannotSpecifySearchFolderAsSourceFolder", + "ErrorCannotUseFolderIdForItemId", + "ErrorCannotUseItemIdForFolderId", + "ErrorChangeKeyRequired", + "ErrorChangeKeyRequiredForWriteOperations", + "ErrorClientDisconnected", + "ErrorClientIntentInvalidStateDefinition", + "ErrorClientIntentNotFound", + "ErrorConnectionFailed", + "ErrorContainsFilterWrongType", + "ErrorContentConversionFailed", + "ErrorContentIndexingNotEnabled", + "ErrorCorruptData", + "ErrorCreateItemAccessDenied", + "ErrorCreateManagedFolderPartialCompletion", + "ErrorCreateSubfolderAccessDenied", + "ErrorCrossMailboxMoveCopy", + "ErrorCrossSiteRequest", + "ErrorDataSizeLimitExceeded", + "ErrorDataSourceOperation", + "ErrorDelegateAlreadyExists", + "ErrorDelegateCannotAddOwner", + "ErrorDelegateMissingConfiguration", + "ErrorDelegateNoUser", + "ErrorDelegateValidationFailed", + "ErrorDeleteDistinguishedFolder", + "ErrorDeleteItemsFailed", + "ErrorDeleteUnifiedMessagingPromptFailed", + "ErrorDistinguishedUserNotSupported", + "ErrorDistributionListMemberNotExist", + "ErrorDuplicateInputFolderNames", + "ErrorDuplicateUserIdsSpecified", + "ErrorDuplicateTransactionId", + "ErrorEmailAddressMismatch", + "ErrorEventNotFound", + "ErrorExceededConnectionCount", + "ErrorExceededSubscriptionCount", + "ErrorExceededFindCountLimit", + "ErrorExpiredSubscription", + "ErrorExtensionNotFound", + "ErrorExtensionsNotAuthorized", + "ErrorFolderCorrupt", + "ErrorFolderNotFound", + "ErrorFolderPropertRequestFailed", + "ErrorFolderSave", + "ErrorFolderSaveFailed", + "ErrorFolderSavePropertyError", + "ErrorFolderExists", + "ErrorFreeBusyGenerationFailed", + "ErrorGetServerSecurityDescriptorFailed", + "ErrorImContactLimitReached", + "ErrorImGroupDisplayNameAlreadyExists", + "ErrorImGroupLimitReached", + "ErrorImpersonateUserDenied", + "ErrorImpersonationDenied", + "ErrorImpersonationFailed", + "ErrorIncorrectSchemaVersion", + "ErrorIncorrectUpdatePropertyCount", + "ErrorIndividualMailboxLimitReached", + "ErrorInsufficientResources", + "ErrorInternalServerError", + "ErrorInternalServerTransientError", + "ErrorInvalidAccessLevel", + "ErrorInvalidArgument", + "ErrorInvalidAttachmentId", + "ErrorInvalidAttachmentSubfilter", + "ErrorInvalidAttachmentSubfilterTextFilter", + "ErrorInvalidAuthorizationContext", + "ErrorInvalidChangeKey", + "ErrorInvalidClientSecurityContext", + "ErrorInvalidCompleteDate", + "ErrorInvalidContactEmailAddress", + "ErrorInvalidContactEmailIndex", + "ErrorInvalidCrossForestCredentials", + "ErrorInvalidDelegatePermission", + "ErrorInvalidDelegateUserId", + "ErrorInvalidExcludesRestriction", + "ErrorInvalidExpressionTypeForSubFilter", + "ErrorInvalidExtendedProperty", + "ErrorInvalidExtendedPropertyValue", + "ErrorInvalidFolderId", + "ErrorInvalidFolderTypeForOperation", + "ErrorInvalidFractionalPagingParameters", + "ErrorInvalidFreeBusyViewType", + "ErrorInvalidId", + "ErrorInvalidIdEmpty", + "ErrorInvalidIdMalformed", + "ErrorInvalidIdMalformedEwsLegacyIdFormat", + "ErrorInvalidIdMonikerTooLong", + "ErrorInvalidIdNotAnItemAttachmentId", + "ErrorInvalidIdReturnedByResolveNames", + "ErrorInvalidIdStoreObjectIdTooLong", + "ErrorInvalidIdTooManyAttachmentLevels", + "ErrorInvalidIdXml", + "ErrorInvalidImContactId", + "ErrorInvalidImDistributionGroupSmtpAddress", + "ErrorInvalidImGroupId", + "ErrorInvalidIndexedPagingParameters", + "ErrorInvalidInternetHeaderChildNodes", + "ErrorInvalidItemForOperationArchiveItem", + "ErrorInvalidItemForOperationCreateItemAttachment", + "ErrorInvalidItemForOperationCreateItem", + "ErrorInvalidItemForOperationAcceptItem", + "ErrorInvalidItemForOperationDeclineItem", + "ErrorInvalidItemForOperationCancelItem", + "ErrorInvalidItemForOperationExpandDL", + "ErrorInvalidItemForOperationRemoveItem", + "ErrorInvalidItemForOperationSendItem", + "ErrorInvalidItemForOperationTentative", + "ErrorInvalidLogonType", + "ErrorInvalidLikeRequest", + "ErrorInvalidMailbox", + "ErrorInvalidManagedFolderProperty", + "ErrorInvalidManagedFolderQuota", + "ErrorInvalidManagedFolderSize", + "ErrorInvalidMergedFreeBusyInterval", + "ErrorInvalidNameForNameResolution", + "ErrorInvalidOperation", + "ErrorInvalidNetworkServiceContext", + "ErrorInvalidOofParameter", + "ErrorInvalidPagingMaxRows", + "ErrorInvalidParentFolder", + "ErrorInvalidPercentCompleteValue", + "ErrorInvalidPermissionSettings", + "ErrorInvalidPhoneCallId", + "ErrorInvalidPhoneNumber", + "ErrorInvalidUserInfo", + "ErrorInvalidPropertyAppend", + "ErrorInvalidPropertyDelete", + "ErrorInvalidPropertyForExists", + "ErrorInvalidPropertyForOperation", + "ErrorInvalidPropertyRequest", + "ErrorInvalidPropertySet", + "ErrorInvalidPropertyUpdateSentMessage", + "ErrorInvalidProxySecurityContext", + "ErrorInvalidPullSubscriptionId", + "ErrorInvalidPushSubscriptionUrl", + "ErrorInvalidRecipients", + "ErrorInvalidRecipientSubfilter", + "ErrorInvalidRecipientSubfilterComparison", + "ErrorInvalidRecipientSubfilterOrder", + "ErrorInvalidRecipientSubfilterTextFilter", + "ErrorInvalidReferenceItem", + "ErrorInvalidRequest", + "ErrorInvalidRestriction", + "ErrorInvalidRetentionTagTypeMismatch", + "ErrorInvalidRetentionTagInvisible", + "ErrorInvalidRetentionTagInheritance", + "ErrorInvalidRetentionTagIdGuid", + "ErrorInvalidRoutingType", + "ErrorInvalidScheduledOofDuration", + "ErrorInvalidSchemaVersionForMailboxVersion", + "ErrorInvalidSecurityDescriptor", + "ErrorInvalidSendItemSaveSettings", + "ErrorInvalidSerializedAccessToken", + "ErrorInvalidServerVersion", + "ErrorInvalidSid", + "ErrorInvalidSIPUri", + "ErrorInvalidSmtpAddress", + "ErrorInvalidSubfilterType", + "ErrorInvalidSubfilterTypeNotAttendeeType", + "ErrorInvalidSubfilterTypeNotRecipientType", + "ErrorInvalidSubscription", + "ErrorInvalidSubscriptionRequest", + "ErrorInvalidSyncStateData", + "ErrorInvalidTimeInterval", + "ErrorInvalidUserOofSettings", + "ErrorInvalidUserPrincipalName", + "ErrorInvalidUserSid", + "ErrorInvalidUserSidMissingUPN", + "ErrorInvalidValueForProperty", + "ErrorInvalidWatermark", + "ErrorIPGatewayNotFound", + "ErrorIrresolvableConflict", + "ErrorItemCorrupt", + "ErrorItemNotFound", + "ErrorItemPropertyRequestFailed", + "ErrorItemSave", + "ErrorItemSavePropertyError", + "ErrorLegacyMailboxFreeBusyViewTypeNotMerged", + "ErrorLocalServerObjectNotFound", + "ErrorLogonAsNetworkServiceFailed", + "ErrorMailboxConfiguration", + "ErrorMailboxDataArrayEmpty", + "ErrorMailboxDataArrayTooBig", + "ErrorMailboxHoldNotFound", + "ErrorMailboxLogonFailed", + "ErrorMailboxMoveInProgress", + "ErrorMailboxStoreUnavailable", + "ErrorMailRecipientNotFound", + "ErrorMailTipsDisabled", + "ErrorManagedFolderAlreadyExists", + "ErrorManagedFolderNotFound", + "ErrorManagedFoldersRootFailure", + "ErrorMeetingSuggestionGenerationFailed", + "ErrorMessageDispositionRequired", + "ErrorMessageSizeExceeded", + "ErrorMimeContentConversionFailed", + "ErrorMimeContentInvalid", + "ErrorMimeContentInvalidBase64String", + "ErrorMissingArgument", + "ErrorMissingEmailAddress", + "ErrorMissingEmailAddressForManagedFolder", + "ErrorMissingInformationEmailAddress", + "ErrorMissingInformationReferenceItemId", + "ErrorMissingItemForCreateItemAttachment", + "ErrorMissingManagedFolderId", + "ErrorMissingRecipients", + "ErrorMissingUserIdInformation", + "ErrorMoreThanOneAccessModeSpecified", + "ErrorMoveCopyFailed", + "ErrorMoveDistinguishedFolder", + "ErrorMoveUnifiedGroupPropertyFailed", + "ErrorMultiLegacyMailboxAccess", + "ErrorNameResolutionMultipleResults", + "ErrorNameResolutionNoMailbox", + "ErrorNameResolutionNoResults", + "ErrorNoApplicableProxyCASServersAvailable", + "ErrorNoCalendar", + "ErrorNoDestinationCASDueToKerberosRequirements", + "ErrorNoDestinationCASDueToSSLRequirements", + "ErrorNoDestinationCASDueToVersionMismatch", + "ErrorNoFolderClassOverride", + "ErrorNoFreeBusyAccess", + "ErrorNonExistentMailbox", + "ErrorNonPrimarySmtpAddress", + "ErrorNoPropertyTagForCustomProperties", + "ErrorNoPublicFolderReplicaAvailable", + "ErrorNoPublicFolderServerAvailable", + "ErrorNoRespondingCASInDestinationSite", + "ErrorNotDelegate", + "ErrorNotEnoughMemory", + "ErrorObjectTypeChanged", + "ErrorOccurrenceCrossingBoundary", + "ErrorOccurrenceTimeSpanTooBig" , + "ErrorOperationNotAllowedWithPublicFolderRoot" , + "ErrorParentFolderIdRequired", + "ErrorParentFolderNotFound", + "ErrorPasswordChangeRequired", + "ErrorPasswordExpired", + "ErrorPhoneNumberNotDialable", + "ErrorPropertyUpdate", + "ErrorPromptPublishingOperationFailed", + "ErrorPropertyValidationFailure", + "ErrorProxiedSubscriptionCallFailure", + "ErrorProxyCallFailed", + "ErrorProxyGroupSidLimitExceeded", + "ErrorProxyRequestNotAllowed", + "ErrorProxyRequestProcessingFailed", + "ErrorProxyServiceDiscoveryFailed", + "ErrorProxyTokenExpired", + "ErrorPublicFolderMailboxDiscoveryFailed", + "ErrorPublicFolderOperationFailed", + "ErrorPublicFolderRequestProcessingFailed", + "ErrorPublicFolderServerNotFound", + "ErrorPublicFolderSyncException", + "ErrorQueryFilterTooLong", + "ErrorQuotaExceeded", + "ErrorReadEventsFailed", + "ErrorReadReceiptNotPending", + "ErrorRecurrenceEndDateTooBig", + "ErrorRecurrenceHasNoOccurrence", + "ErrorRemoveDelegatesFailed", + "ErrorRequestAborted", + "ErrorRequestStreamTooBig", + "ErrorRequiredPropertyMissing", + "ErrorResolveNamesInvalidFolderType", + "ErrorResolveNamesOnlyOneContactsFolderAllowed", + "ErrorResponseSchemaValidation", + "ErrorRestrictionTooLong", + "ErrorRestrictionTooComplex", + "ErrorResultSetTooBig", + "ErrorInvalidExchangeImpersonationHeaderData", + "ErrorSavedItemFolderNotFound", + "ErrorSchemaValidation", + "ErrorSearchFolderNotInitialized", + "ErrorSendAsDenied", + "ErrorSendMeetingCancellationsRequired", + "ErrorSendMeetingInvitationsOrCancellationsRequired", + "ErrorSendMeetingInvitationsRequired", + "ErrorSentMeetingRequestUpdate", + "ErrorSentTaskRequestUpdate", + "ErrorServerBusy", + "ErrorServiceDiscoveryFailed", + "ErrorStaleObject", + "ErrorSubmissionQuotaExceeded", + "ErrorSubscriptionAccessDenied", + "ErrorSubscriptionDelegateAccessNotSupported", + "ErrorSubscriptionNotFound", + "ErrorSubscriptionUnsubscribed", + "ErrorSyncFolderNotFound", + "ErrorTeamMailboxNotFound", + "ErrorTeamMailboxNotLinkedToSharePoint", + "ErrorTeamMailboxUrlValidationFailed", + "ErrorTeamMailboxNotAuthorizedOwner", + "ErrorTeamMailboxActiveToPendingDelete", + "ErrorTeamMailboxFailedSendingNotifications", + "ErrorTeamMailboxErrorUnknown", + "ErrorTimeIntervalTooBig", + "ErrorTimeoutExpired", + "ErrorTimeZone", + "ErrorToFolderNotFound", + "ErrorTokenSerializationDenied", + "ErrorTooManyObjectsOpened", + "ErrorUpdatePropertyMismatch", + "ErrorAccessingPartialCreatedUnifiedGroup", + "ErrorUnifiedGroupMailboxAADCreationFailed", + "ErrorUnifiedGroupMailboxAADDeleteFailed", + "ErrorUnifiedGroupMailboxNamingPolicy", + "ErrorUnifiedGroupMailboxDeleteFailed", + "ErrorUnifiedGroupMailboxNotFound", + "ErrorUnifiedGroupMailboxUpdateDelayed", + "ErrorUnifiedGroupMailboxUpdatedPartialProperties", + "ErrorUnifiedGroupMailboxUpdateFailed", + "ErrorUnifiedGroupMailboxProvisionFailed", + "ErrorUnifiedMessagingDialPlanNotFound", + "ErrorUnifiedMessagingReportDataNotFound", + "ErrorUnifiedMessagingPromptNotFound", + "ErrorUnifiedMessagingRequestFailed", + "ErrorUnifiedMessagingServerNotFound", + "ErrorUnableToGetUserOofSettings", + "ErrorUnableToRemoveImContactFromGroup", + "ErrorUnsupportedSubFilter", + "ErrorUnsupportedCulture", + "ErrorUnsupportedMapiPropertyType", + "ErrorUnsupportedMimeConversion", + "ErrorUnsupportedPathForQuery", + "ErrorUnsupportedPathForSortGroup", + "ErrorUnsupportedPropertyDefinition", + "ErrorUnsupportedQueryFilter", + "ErrorUnsupportedRecurrence", + "ErrorUnsupportedTypeForConversion", + "ErrorUpdateDelegatesFailed", + "ErrorUserNotUnifiedMessagingEnabled", + "ErrorVoiceMailNotImplemented", + "ErrorValueOutOfRange", + "ErrorVirusDetected", + "ErrorVirusMessageDeleted", + "ErrorWebRequestInInvalidState", + "ErrorWin32InteropError", + "ErrorWorkingHoursSaveFailed", + "ErrorWorkingHoursXmlMalformed", + "ErrorWrongServerVersion", + "ErrorWrongServerVersionDelegate", + "ErrorMissingInformationSharingFolderId", + "ErrorDuplicateSOAPHeader" , + "ErrorSharingSynchronizationFailed" , + "ErrorSharingNoExternalEwsAvailable" , + "ErrorFreeBusyDLLimitReached", + "ErrorInvalidGetSharingFolderRequest" , + "ErrorNotAllowedExternalSharingByPolicy" , + "ErrorUserNotAllowedByPolicy" , + "ErrorPermissionNotAllowedByPolicy" , + "ErrorOrganizationNotFederated" , + "ErrorMailboxFailover" , + "ErrorInvalidExternalSharingInitiator" , + "ErrorMessageTrackingPermanentError" , + "ErrorMessageTrackingTransientError" , + "ErrorMessageTrackingNoSuchDomain" , + "ErrorUserWithoutFederatedProxyAddress" , + "ErrorInvalidOrganizationRelationshipForFreeBusy" , + "ErrorInvalidFederatedOrganizationId" , + "ErrorInvalidExternalSharingSubscriber" , + "ErrorInvalidSharingData" , + "ErrorInvalidSharingMessage" , + "ErrorNotSupportedSharingMessage" , + "ErrorApplyConversationActionFailed" , + "ErrorInboxRulesValidationError" , + "ErrorOutlookRuleBlobExists" , + "ErrorRulesOverQuota" , + "ErrorNewEventStreamConnectionOpened" , + "ErrorMissedNotificationEvents" , + "ErrorDuplicateLegacyDistinguishedName" , + "ErrorInvalidClientAccessTokenRequest" , + "ErrorUnauthorizedClientAccessTokenRequest" , + "ErrorNoSpeechDetected" , + "ErrorUMServerUnavailable" , + "ErrorRecipientNotFound" , + "ErrorRecognizerNotInstalled" , + "ErrorSpeechGrammarError" , + "ErrorInvalidManagementRoleHeader" , + "ErrorLocationServicesDisabled", + "ErrorLocationServicesRequestTimedOut", + "ErrorLocationServicesRequestFailed", + "ErrorLocationServicesInvalidRequest", + "ErrorWeatherServiceDisabled", + "ErrorMailboxScopeNotAllowedWithoutQueryString" , + "ErrorArchiveMailboxSearchFailed" , + "ErrorGetRemoteArchiveFolderFailed" , + "ErrorFindRemoteArchiveFolderFailed" , + "ErrorGetRemoteArchiveItemFailed" , + "ErrorExportRemoteArchiveItemsFailed" , + "ErrorInvalidPhotoSize" , + "ErrorSearchQueryHasTooManyKeywords", + "ErrorSearchTooManyMailboxes", + "ErrorInvalidRetentionTagNone", + "ErrorDiscoverySearchesDisabled", + "ErrorCalendarSeekToConditionNotSupported", + "ErrorCalendarIsGroupMailboxForAccept", + "ErrorCalendarIsGroupMailboxForDecline", + "ErrorCalendarIsGroupMailboxForTentative", + "ErrorCalendarIsGroupMailboxForSuppressReadReceipt", + "ErrorOrganizationAccessBlocked", + "ErrorInvalidLicense", + "ErrorMessagePerFolderCountReceiveQuotaExceeded", + "ErrorInvalidBulkActionType", + "ErrorInvalidKeepNCount", + "ErrorInvalidKeepNType", + "ErrorNoOAuthServerAvailableForRequest", + "ErrorInstantSearchSessionExpired", + "ErrorInstantSearchTimeout", + "ErrorInstantSearchFailed", + "ErrorUnsupportedUserForExecuteSearch", + "ErrorDuplicateExtendedKeywordDefinition", + "ErrorMissingExchangePrincipal", + "ErrorUnexpectedUnifiedGroupsCount", + "ErrorParsingXMLResponse", + "ErrorInvalidFederationOrganizationIdentifier", + "ErrorInvalidSweepRule", + "ErrorInvalidSweepRuleOperationType", + "ErrorTargetDomainNotSupported", + "ErrorInvalidInternetWebProxyOnLocalServer", + "ErrorNoSenderRestrictionsSettingsFoundInRequest", + "ErrorDuplicateSenderRestrictionsInputFound", + "ErrorSenderRestrictionsUpdateFailed", + "ErrorMessageSubmissionBlocked", + "ErrorExceededMessageLimit", + "ErrorExceededMaxRecipientLimitBlock", + "ErrorAccountSuspend", + "ErrorExceededMaxRecipientLimit", + "ErrorMessageBlocked", + "ErrorAccountSuspendShowTierUpgrade", + "ErrorExceededMessageLimitShowTierUpgrade", + "ErrorExceededMaxRecipientLimitShowTierUpgrade", + "ErrorInvalidLongitude", + "ErrorInvalidLatitude", + "ErrorProxySoapException", + "ErrorUnifiedGroupAlreadyExists", + "ErrorUnifiedGroupAadAuthorizationRequestDenied", + "ErrorUnifiedGroupCreationDisabled", + "ErrorMarketPlaceExtensionAlreadyInstalledForOrg", + "ErrorExtensionAlreadyInstalledForOrg", + "ErrorNewerExtensionAlreadyInstalled", + "ErrorNewerMarketPlaceExtensionAlreadyInstalled", + "ErrorInvalidExtensionId", + "ErrorCannotUninstallProvidedExtensions", + "ErrorNoRbacPermissionToInstallMarketPlaceExtensions", + "ErrorNoRbacPermissionToInstallReadWriteMailboxExtensions", + "ErrorInvalidReportMessageActionType", + "ErrorCannotDownloadExtensionManifest", + "ErrorCalendarForwardActionNotAllowed", + "ErrorUnifiedGroupAliasNamingPolicy", + "ErrorSubscriptionsDisabledForGroup", + "ErrorCannotFindFileAttachment", + "ErrorInvalidValueForFilter", + "ErrorQuotaExceededOnDelete", + "ErrorAccessDeniedDueToCompliance", + "ErrorRecoverableItemsAccessDenied" +] \ No newline at end of file From 9097d0b1b3b6b8615ca703b8f441f286e6ebd2cf Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:42:19 -0500 Subject: [PATCH 68/72] Improved handling of logging in base class --- pyews/service/base.py | 80 ++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/pyews/service/base.py b/pyews/service/base.py index 1c64e5b..4db658e 100644 --- a/pyews/service/base.py +++ b/pyews/service/base.py @@ -5,6 +5,7 @@ from bs4 import BeautifulSoup from ..core import Core, Authentication +from ..utils.attributes import RESPONSE_CODES class Base(Core): @@ -13,7 +14,7 @@ class Base(Core): It is inherited by the Autodiscover & Operation classes """ - SOAP_REQUEST_HEADER = {'content-type': 'text/xml; charset=UTF-8'} + SOAP_REQUEST_HEADER = {'content-type': 'text/xml', 'charset': 'UTF-8'} NAMESPACE_MAP = { 'soap': "http://schemas.xmlsoap.org/soap/envelope/", 'm': "http://schemas.microsoft.com/exchange/services/2006/messages", @@ -86,6 +87,18 @@ def __process_response(self, response): namespace_dict[val] = None return self.parse_response(response, namespace_dict=namespace_dict) + def __log_warning(self, kwargs): + log_message = f''' +The server responded with: + response code: {kwargs.get('response_code')} + error code: {kwargs.get('error_code')} + from: {kwargs.get('from')} + error message: {kwargs.get('message_text')} +''' + if kwargs.get('additional_text'): + log_message += f'''\n\t{kwargs.get('additional_text')}''' + return log_message + def run(self): """The Base class run method is used for all SOAP requests for every endpoint defined @@ -127,6 +140,7 @@ def run(self): self.__logger.debug('Response HTTP status code: {}'.format(response.status_code)) self.__logger.debug('Response text: {}'.format(response.text)) + self.__logger.debug('Response content: {}'.format(response.content)) parsed_response = BeautifulSoup(response.content, 'xml') if not parsed_response.contents: @@ -143,40 +157,44 @@ def run(self): if 'NoError' in (response_code, error_code): return self.__process_response(parsed_response) - elif error_message: - self.__logger.warning( - 'The server responded with "{response_code}" ' - 'response code to POST-request from {current} with error message "{error_message}"'.format( - current=self.__class__.__name__, - error_message=error_message, - response_code=response_code)) - elif 'ErrorAccessDenied' in (response_code, error_code): - self.__logger.warning( - 'The server responded with "ErrorAccessDenied" ' - 'response code to POST-request from {current} with error message "{message_text}"'.format( - current=self.__class__.__name__, - message_text=message_text)) - elif 'ErrorInvalidIdMalformed' in (response_code, error_code): - self.__logger.warning( - 'The server responded with "ErrorInvalidIdMalformed" ' - 'response code to POST-request from {current} with error message "{message_text}"'.format( - current=self.__class__.__name__, - message_text=message_text)) + if response_code in RESPONSE_CODES or error_code in RESPONSE_CODES or error_message: + warning_dict = { + 'response_code': response_code, + 'error_code': error_code, + 'from': self.__class__.__name__, + 'message_text': message_text + } if 'ConvertId' in message_text: return self.__parse_convert_id_error_message(message_text) + if 'ErrorAccessDenied' in (response_code, error_code) and endpoint in ('GetSearchableMailboxes', 'SearchMailboxes'): + warning_dict.update({ + 'additional_text': 'Please make sure you have Discovery Management rights: https://docs.microsoft.com/en-us/Exchange/policy-and-compliance/ediscovery/assign-permissions?redirectedfrom=MSDN&view=exchserver-2019' + }) + elif error_message: + warning_dict.update({ + 'additional_text': error_message + }) + self.__logger.info(self.__log_warning(warning_dict)) + continue elif fault_message or fault_string: - self.__logger.warning( - 'The server responded with a "{fault_message}" ' - 'to POST-request from {current} with error message "{fault_string}"'.format( - current=self.__class__.__name__, - fault_message=fault_message, - fault_string=fault_string)) + warning_dict = { + 'response_code': response_code, + 'error_code': error_code, + 'from': self.__class__.__name__, + 'message_text': fault_message, + 'additional_text': fault_string + } + self.__logger.info(self.__log_warning(warning_dict)) + continue else: - self.__logger.warning( - 'The server responded with unknown "ResponseCode" ' - 'and "ErrorCode" from {current} with error message "{message_text}"'.format( - current=self.__class__.__name__, - message_text=message_text)) + warning_dict = { + 'response_code': 'Unknown', + 'error_code': 'Unknown', + 'from': self.__class__.__name__, + 'message_text': message_text, + 'additional_text': 'This error is unrecognized and is not a valid response code or error code.' + } + self.__logger.info(self.__log_warning(warning_dict)) continue except: pass From 71724f9db45c76dcc05ef50a1e76b4f848f8ebd8 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:42:35 -0500 Subject: [PATCH 69/72] Exposed more endpoints in EWS interface --- pyews/ews.py | 147 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 116 insertions(+), 31 deletions(-) diff --git a/pyews/ews.py b/pyews/ews.py index da2982c..fab8697 100644 --- a/pyews/ews.py +++ b/pyews/ews.py @@ -1,8 +1,7 @@ import os from concurrent.futures import ThreadPoolExecutor, as_completed - from .core import Authentication -from .endpoint import GetSearchableMailboxes, GetUserSettings, ResolveNames, SearchMailboxes, ExecuteSearch, GetInboxRules, GetItem, ConvertId, GetHiddenInboxRules, CreateItem, GetServiceConfiguration, SyncFolderHierarchy, SyncFolderItems, GetAttachment, DeleteItem, GetDomainSettings +from .endpoint import GetSearchableMailboxes, GetUserSettings, ResolveNames, SearchMailboxes, ExecuteSearch, GetInboxRules, GetItem, ConvertId, GetHiddenInboxRules, CreateItem, GetServiceConfiguration, SyncFolderHierarchy, SyncFolderItems, GetAttachment, DeleteItem, GetDomainSettings, FindItem, CreateFolder, FindFolder, DeleteFolder class EWS: @@ -41,6 +40,26 @@ def resolve_names(self, user=None): def __execute_multithreaded_search(self, query, reference_id, scope): return SearchMailboxes(query=query, reference_id=reference_id, search_scope=scope).run() + def __execute_multithreaded_find( + self, + query_string, + user_list, + distinguished_folder_name='inbox', + base_shape='AllProperties', + include_mime_content=True, + body_type='Best', + traversal='Shallow', + reset_cache=False, + return_deleted_items=True, + return_highlight_terms=True + ): + return_list = [] + for user in user_list: + if user: + Authentication.impersonate_as = user + return_list.extend(FindItem(query_string=query_string, distinguished_folder_name=distinguished_folder_name, base_shape=base_shape, include_mime_content=include_mime_content, body_type=body_type, traversal=traversal, reset_cache=reset_cache, return_deleted_items=return_deleted_items, return_highlight_terms=return_highlight_terms).run()) + return return_list + def execute_ews_search(self, query, reference_id, search_scope='All', thread_count=os.cpu_count()*5): response = [] return_list = [] @@ -58,24 +77,26 @@ def execute_ews_search(self, query, reference_id, search_scope='All', thread_cou else: response = SearchMailboxes(query, reference_id=reference_id, search_scope=search_scope).run() for item in response: - return_dict = item - get_item_response = self.get_item(return_dict['id'].get('id')) - if get_item_response: - for item_response in get_item_response: - if item_response.get('message').get('attachments') and item_response.get('message').get('attachments').get('file_attachment').get('attachment_id').get('id'): - attachment_details_list = [] - attachment = self.get_attachment(item_response.get('message').get('attachments').get('file_attachment').get('attachment_id').get('id')) - for attach in attachment: - attachment_dict = {} - for key,val in attach.items(): - for k,v in val.items(): - attachment_dict[k] = v - if attachment_dict: - attachment_details_list.append(attachment_dict) - if attachment_details_list: - return_dict.update({'attachment_details': attachment_details_list}) - return_dict.update(item_response.pop('message')) - return_list.append(return_dict) + if item: + return_dict = item + get_item_response = self.get_item(return_dict['id'].get('id')) + if get_item_response: + for item_response in get_item_response: + if item_response and item_response.get('message').get('attachments') and item_response.get('message').get('attachments').get('file_attachment').get('attachment_id').get('id'): + attachment_details_list = [] + attachment = self.get_attachment(item_response.get('message').get('attachments').get('file_attachment').get('attachment_id').get('id')) + for attach in attachment: + attachment_dict = {} + if attach: + for key,val in attach.items(): + for k,v in val.items(): + attachment_dict[k] = v + if attachment_dict: + attachment_details_list.append(attachment_dict) + if attachment_details_list: + return_dict.update({'attachment_details': attachment_details_list}) + return_dict.update(item_response.pop('message')) + return_list.append(return_dict) return return_list def execute_outlook_search(self, query, result_row_count='25', max_results_count='-1'): @@ -93,7 +114,7 @@ def get_hidden_inbox_rules(self): def get_item(self, item_id, change_key=None): response = GetItem(item_id, change_key=change_key).run() - if isinstance(response, list): + if isinstance(response, list) and response: if any(item in response for item in ConvertId.ID_FORMATS): convert_id_response = ConvertId(Authentication.credentials[0], item_id, id_type=response[0], convert_to=response[1]).run() get_item_response = GetItem(convert_id_response[0]).run() @@ -115,21 +136,85 @@ def create_item(self, subject, sender, to_recipients, body_type='HTML'): def delete_item(self, item_id, delete_type='MoveToDeletedItems'): if not isinstance(item_id, list): item_id = [item_id] + return_list = [] for item in item_id: - deleted_item = DeleteItem(item, delete_type=delete_type).run() + if item: + return_list.append(DeleteItem(item, delete_type=delete_type).run()) + return return_list def search_and_delete_message(self, query, thread_count=os.cpu_count()*5, what_if=False): + return_list = [] reference_id_list = [] for mailbox in self.get_searchable_mailboxes(): - reference_id_list.append(mailbox.get('reference_id')) - count = 1 - for item in self.execute_ews_search(query, reference_id_list, thread_count=thread_count): - if count == 1: - if what_if: - print('WHAT IF: About to delete message ID: {}'.format(item.get('id').get('id'))) - else: - self.delete_item(item.get('id').get('id')) - count += 1 + if mailbox: + reference_id_list.append(mailbox.get('reference_id')) + if not reference_id_list: + print('No searchable mailboxes found') + return None + else: + for item in self.execute_ews_search(query, reference_id_list, thread_count=thread_count): + if item: + if what_if: + print('WHAT IF: About to delete message ID: {}'.format(item.get('id').get('id'))) + else: + return_list.extend(self.delete_item(item.get('id').get('id'))) + return return_list def get_domain_settings(self, domain=None): return GetDomainSettings(domain=domain).run() + + def find_items(self, + query_string, + distinguished_folder_name='inbox', + base_shape='AllProperties', + include_mime_content=True, + body_type='Best', + traversal='Shallow', + reset_cache=False, + return_deleted_items=True, + return_highlight_terms=True + ): + return_list = [] + for message in FindItem(query_string=query_string, distinguished_folder_name=distinguished_folder_name, base_shape=base_shape, include_mime_content=include_mime_content, body_type=body_type, traversal=traversal, reset_cache=reset_cache, return_deleted_items=return_deleted_items, return_highlight_terms=return_highlight_terms).run(): + if message: + items = self.get_item(item_id=message.get('item_id').get('id')) + if items: + for item in items: + return_list.append(item) + return return_list + + def search_mailboxes_using_find_item(self, + query_string, + impersonation_list, + multi_threading=True, + thread_count=os.cpu_count()*5, + distinguished_folder_name='inbox', + base_shape='AllProperties', + include_mime_content=True, + body_type='Best', + traversal='Shallow', + reset_cache=False, + return_deleted_items=True, + return_highlight_terms=True): + if multi_threading: + threads = [] + response = [] + chunks = self.chunk(impersonation_list, int(len(impersonation_list) / thread_count)) + with ThreadPoolExecutor(max_workers=thread_count) as executor: + for chunk in chunks: + threads.append(executor.submit(self.__execute_multithreaded_find, query_string, distinguished_folder_name, base_shape, include_mime_content, body_type, traversal, reset_cache, return_deleted_items, return_highlight_terms, chunk)) + for task in as_completed(threads): + result = task.result() + if isinstance(result, list): + for item in result: + response.append(item) + return response + + def create_search_folder(self, search_string, display_name='Search Folder', base_folder='inbox', traversal='Deep'): + return CreateFolder(search_string=search_string, search_folder=True, display_name=display_name, base_folder=base_folder, traversal=traversal).run() + + def find_search_folder(self): + return FindFolder().run() + + def delete_search_folder(self, folder_id): + return DeleteFolder(folder_id=folder_id) From c13f9156582ad7d6dfa15995b8b65366af5fe9fd Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Fri, 25 Jun 2021 09:49:59 -0500 Subject: [PATCH 70/72] Updated documentation --- README.md | 18 ++++++++++++++++++ docs/endpoint/root.md | 4 ++++ docs/index.md | 14 ++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/README.md b/README.md index efaaed7..151277d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ * Autodiscover support * Delegation support * Impersonation support +* OAUth2 support * Retrieve all mailboxes that can be searched based on credentials provided * Search a list of (or single) mailboxes in your Exchange environment using all supported search attributes * Delete email items from mailboxes in your Exchange environment @@ -39,10 +40,14 @@ Currently this package supports the following endpoint's: * [AddDelegate](docs/endpoint/adddelegate.md) * [ConvertId](docs/endpoint/convertid.md) +* [CreateFolder](docs/endpoint/createfolder.md) * [CreateItem](docs/endpoint/createitem.md) +* [DeleteFolder](docs/endpoint/deletefolder.md) * [DeleteItem](docs/endpoint/deleteitem.md) * [ExecuteSearch](docs/endpoint/executesearch.md) * [ExpandDL](docs/endpoint/expanddl.md) +* [FindFolder](docs/endpoint/findfolder.md) +* [FindItem](docs/endpoint/finditem.md) * [GetAttachment](docs/endpoint/getattachment.md) * [GetHiddenInboxRules](docs/endpoint/gethiddeninboxrules.md) * [GetInboxRules](docs/endpoint/getinboxrules.md) @@ -114,6 +119,10 @@ ews = EWS( ) ``` +### Exchange Search Multi-Threading + +You can also specify `multi_threading=True` and when you search mailboxes we will use multi-threading to perform the search. + ## Using Provided Methods Once you have instantiated the EWS class with your credentials, you will have access to pre-exposed methods for each endpoint. These methods are: @@ -127,9 +136,18 @@ Once you have instantiated the EWS class with your credentials, you will have ac * get_inbox_rules * get_hidden_inbox_rules * get_item +* get_attachment * sync_folder_hierarchy * sync_folder_items * create_item +* delete_item +* search_and_delete_message +* get_domain_settings +* find_items +* search_mailboxes_using_find_item +* create_search_folder +* find_search_folder +* delete_search_folder ## Access Classes Directly diff --git a/docs/endpoint/root.md b/docs/endpoint/root.md index f070d06..7870e1c 100644 --- a/docs/endpoint/root.md +++ b/docs/endpoint/root.md @@ -10,10 +10,14 @@ All endpoints inherit from either the Autodiscover or Operation classes. These c adddelegate convertid + createfolder createitem + deletefolder deleteitem executesearch expanddl + findfolder + finditem getattachment gethiddeninboxrules getinboxrules diff --git a/docs/index.md b/docs/index.md index 3454660..1d0f285 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,6 +28,7 @@ * Autodiscover support * Delegation support * Impersonation support +* OAUth2 support * Retrieve all mailboxes that can be searched based on credentials provided * Search a list of (or single) mailboxes in your Exchange environment using all supported search attributes * Delete email items from mailboxes in your Exchange environment @@ -39,10 +40,14 @@ Currently this package supports the following endpoint's: * [AddDelegate](endpoint/adddelegate.md) * [ConvertId](endpoint/convertid.md) +* [CreateFolder](endpoint/createfolder.md) * [CreateItem](endpoint/createitem.md) +* [DeleteFolder](endpoint/deletefolder.md) * [DeleteItem](endpoint/deleteitem.md) * [ExecuteSearch](endpoint/executesearch.md) * [ExpandDL](endpoint/expanddl.md) +* [FindFolder](endpoint/findfolder.md) +* [FindItem](endpoint/finditem.md) * [GetAttachment](endpoint/getattachment.md) * [GetHiddenInboxRules](endpoint/gethiddeninboxrules.md) * [GetInboxRules](endpoint/getinboxrules.md) @@ -127,9 +132,18 @@ Once you have instantiated the EWS class with your credentials, you will have ac * get_inbox_rules * get_hidden_inbox_rules * get_item +* get_attachment * sync_folder_hierarchy * sync_folder_items * create_item +* delete_item +* search_and_delete_message +* get_domain_settings +* find_items +* search_mailboxes_using_find_item +* create_search_folder +* find_search_folder +* delete_search_folder ## Access Classes Directly From b07657e03280aea890bccf38c8297368fd88dd59 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 6 Jul 2021 10:20:26 -0500 Subject: [PATCH 71/72] Setting version to 3.0.0 --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index b7fb4ef..df1ef18 100644 --- a/setup.py +++ b/setup.py @@ -5,8 +5,8 @@ def parse_requirements(requirement_file): return f.readlines() setup( - name='py-ews-dev', - version='3.1.0', + name='py-ews', + version='3.0.0', packages=find_packages(exclude=['tests*']), license='MIT', description='A Python package to interact with both on-premises and Office 365 Exchange Web Services', From 99ca58aa4b6a6bad51018c8d8c92fd7836410419 Mon Sep 17 00:00:00 2001 From: joshswimlane Date: Tue, 6 Jul 2021 10:23:52 -0500 Subject: [PATCH 72/72] Modified deletefolder operation --- pyews/endpoint/deletefolder.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyews/endpoint/deletefolder.py b/pyews/endpoint/deletefolder.py index ba7d9e7..a2a25a5 100644 --- a/pyews/endpoint/deletefolder.py +++ b/pyews/endpoint/deletefolder.py @@ -1,6 +1,4 @@ from ..service import Operation -from ..utils.exceptions import UknownValueError -from ..utils.attributes import FOLDER_LIST, TRAVERSAL_LIST class DeleteFolder(Operation): @@ -10,7 +8,7 @@ class DeleteFolder(Operation): RESULTS_KEY = 'Folders' - def __init__(self, folder_id=): + def __init__(self, folder_id): """ """ self.folder_id = folder_id