diff --git a/.github/workflows/pyews.yml b/.github/workflows/pyews.yml new file mode 100644 index 0000000..62c684a --- /dev/null +++ b/.github/workflows/pyews.yml @@ -0,0 +1,35 @@ +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 }} + # install dependencies on Ubuntu + - if: matrix.os == 'ubuntu-latest' + name: Install build dependencies + run: | + sudo apt-get install libxml2-dev libxslt-dev python-dev + - 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: | + pip install -U -r requirements.txt + pip install -U -r test-requirements.txt + python -m pytest \ No newline at end of file diff --git a/README.md b/README.md index e5df5e1..151277d 100644 --- a/README.md +++ b/README.md @@ -28,20 +28,37 @@ * 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 * 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) +* [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) +* [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 +75,104 @@ 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: +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 -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. +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. -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. +Finally, if you would like to `impersonate_as` a specific user you must provide their primary SMTP address when instantiating the `EWS` class object: -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 +from pyews import EWS -userconfig = UserConfiguration( +ews = EWS( 'myaccount@company.com', - 'Password1234' + 'Password1234', + impersonate_as='myotheraccount@company.com' ) - -# 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'}] -``` - - -**For more examples and usage, please refer to the individual class documentation** - -* [Services](docs/services/root.md) -* [Configuration](docs/configuration/root.md) +### 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: + +* 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 +* 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 + +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: -## Development setup +```python +from pyews import Authentication, 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). +Authentication( + 'myaccount@company.com', + 'Password1234' +) -``` -docker build --force-rm -t pyews . +reference_id_list = [] +for mailbox in GetSearchableMailboxes().run(): + reference_id_list.append(mailbox.get('reference_id')) + print(mailbox) ``` -To run the container, use the following: +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. -``` -docker run pyews -``` - -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 +180,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 +197,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/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/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/core/authentication.md b/docs/core/authentication.md new file mode 100644 index 0000000..3a62001 --- /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, ews_url(s) 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 29bd92b..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 +.. autoclass:: pyews.core.exchangeversion.ExchangeVersion :members: :undoc-members: ``` \ No newline at end of file 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/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/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/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/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/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/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/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/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/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..7870e1c --- /dev/null +++ b/docs/endpoint/root.md @@ -0,0 +1,32 @@ +# 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. + +```eval_rst +.. toctree:: + :maxdepth: 2 + + adddelegate + convertid + createfolder + createitem + deletefolder + deleteitem + executesearch + expanddl + findfolder + finditem + 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/index.md b/docs/index.md index f7d8b43..1d0f285 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,20 +28,37 @@ * 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 * 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) +* [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) +* [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 +75,100 @@ 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. - -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. +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. -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. +Finally, if you would like to `impersonate_as` a specific user you must provide their primary SMTP address when instantiating the `EWS` class object: -The returned results will then be deleted (moved to Deleted Items folder) from Exchange using the [DeleteItem](services/deleteitem.md) EWS endpoint. ```python -from pyews import UserConfiguration -from pyews import GetSearchableMailboxes -from pyews import SearchMailboxes -from pyews import DeleteItem +from pyews import EWS -userconfig = UserConfiguration( +ews = EWS( 'myaccount@company.com', - 'Password1234' + 'Password1234', + impersonate_as='myotheraccount@company.com' ) - -# 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'}] -``` - - -**For more examples and usage, please refer to the individual class documentation** - -* [Services](services/root.md) -* [Configuration](configuration/root.md) +### 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: + +* 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 +* 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 + +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: -## Development setup +```python +from pyews import Authentication, 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). +Authentication( + 'myaccount@company.com', + 'Password1234' +) +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: -``` -docker run pyews -``` +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. -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](endpoint/root.md) ## Release History @@ -181,6 +176,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 +199,8 @@ Distributed under the MIT license. See ``LICENSE`` for more information. :maxdepth: 2 :caption: Contents: - services/root - configuration/root + 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 new file mode 100644 index 0000000..22e94f1 --- /dev/null +++ b/docs/service/add_additional_service_endpoints.md @@ -0,0 +1,110 @@ +# 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 pyews import Authentication +from getappmanifests import GetAppManifests + +Authentication('username', 'password') + +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/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 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 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 c2119b4..bdcbbfc 100644 --- a/pyews/__init__.py +++ b/pyews/__init__.py @@ -1,16 +1,5 @@ -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 +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/__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() 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/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 diff --git a/pyews/core.py b/pyews/core.py deleted file mode 100644 index c196f41..0000000 --- a/pyews/core.py +++ /dev/null @@ -1,65 +0,0 @@ -import logging -import requests -import xmltodict -import json -from bs4 import BeautifulSoup - -__LOGGER__ = logging.getLogger(__name__) - - -class Core: - - SOAP_REQUEST_HEADER = {'content-type': 'text/xml; charset=UTF-8'} - - def __init__(self, userconfiguration): - '''Parent class of all endpoints implemented within pyews - - Args: - userconfiguration (UserConfiguration): A UserConfiguration object created using the UserConfiguration class - ''' - self.userconfiguration = userconfiguration - - def camel_to_snake(self, s): - if s != 'UserDN': - return ''.join(['_'+c.lower() if c.isupper() else c for c in s]).lstrip('_') - 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 - - 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 - ) - 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 - 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)) - __LOGGER__.warning('Unable to parse response from {current}'.format(current=self.__class__.__name__)) - return None \ No newline at end of file diff --git a/pyews/core/__init__.py b/pyews/core/__init__.py new file mode 100644 index 0000000..8d2aaef --- /dev/null +++ b/pyews/core/__init__.py @@ -0,0 +1,5 @@ +from .authentication import Authentication +from .core import Core +from .endpoints import Endpoints +from .exchangeversion import ExchangeVersion +from .oauth2connector import OAuth2Connector \ No newline at end of file diff --git a/pyews/core/authentication.py b/pyews/core/authentication.py new file mode 100644 index 0000000..74b811b --- /dev/null +++ b/pyews/core/authentication.py @@ -0,0 +1,190 @@ +from os import access +from .exchangeversion import ExchangeVersion +from .core import Core +from .oauth2connector import OAuth2Connector + + +class AuthenticationProperties(type): + + def __set_initial_property_values(cls): + if isinstance(cls._credentials, tuple): + cls.domain = cls._credentials[0] + if cls.impersonate_as != '': + cls.auth_header = cls._credentials[0] + else: + cls.auth_header = 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: + 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 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() + 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 != '': + cls._auth_header = { + 'X-AnchorMailbox': cls.impersonate_as + } + else: + cls._auth_header = {} + + @property + def impersonate_as(cls): + 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 = '' + else: + cls._impersonate_as = value + + @property + def credentials(cls): + return cls._credentials + + @credentials.setter + def credentials(cls, value): + if isinstance(value, tuple): + cls._credentials = value + cls.__set_initial_property_values() + else: + 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): + if not value: + from .exchangeversion import ExchangeVersion + value = ExchangeVersion.EXCHANGE_VERSIONS + if not isinstance(value, list): + value = [value] + cls._exchange_versions = value + + @property + def ews_url(cls): + return cls._ews_url + + @ews_url.setter + def ews_url(cls, value): + if not value: + from .endpoints import Endpoints + cls._ews_url = Endpoints(cls._domain).get() + elif not isinstance(value, list): + cls._ews_url = [value] + else: + cls._ews_url = value + + @property + def domain(cls): + return cls._domain + + @domain.setter + def domain(cls, value): + 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): + + _auth_header = {} + _oauth2_authorization_type = None + _oauth2_scope = None + _client_id = None + _client_secret = None + _tenant_id = None + _access_token = None + _impersonate_as = None + _credentials = tuple() + _exchange_versions = [] + _ews_url = [] + _domain = None + _redirect_uri = 'https://google.com' diff --git a/pyews/core/core.py b/pyews/core/core.py new file mode 100644 index 0000000..02fc65b --- /dev/null +++ b/pyews/core/core.py @@ -0,0 +1,78 @@ +import xmltodict +import json + +from ..utils.logger import LoggingBase + + +class Core(metaclass=LoggingBase): + """The Core class inherits logging and defines + required authentication details as well as parsing of results + """ + + def camel_to_snake(self, s): + if s != 'UserDN': + return ''.join(['_'+c.lower() if c.isupper() else c for c in s]).lstrip('_') + else: + return 'user_dn' + + 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 _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) diff --git a/pyews/configuration/endpoint.py b/pyews/core/endpoints.py similarity index 61% rename from pyews/configuration/endpoint.py rename to pyews/core/endpoints.py index 446a3b7..70ebe27 100644 --- a/pyews/configuration/endpoint.py +++ b/pyews/core/endpoints.py @@ -1,12 +1,13 @@ - - -class Endpoint: +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'] + 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)) diff --git a/pyews/utils/exchangeversion.py b/pyews/core/exchangeversion.py similarity index 69% rename from pyews/utils/exchangeversion.py rename to pyews/core/exchangeversion.py index ed74b5d..b316fbb 100644 --- a/pyews/utils/exchangeversion.py +++ b/pyews/core/exchangeversion.py @@ -1,36 +1,34 @@ - -class ExchangeVersion(object): +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: + 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) - ``` + ```python + version = ExchangeVersion('15.20.5').exchangeVersion + print(version) + ``` - ```text - # output - Exchange2016 - ``` + ```text + # output + Exchange2016 + ``` - To verify an ExchangeVersion is supported, you can view the supported version by access the EXCHANGE_VERSIONS attribute + 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) - ``` + ```python + versions = ExchangeVersion('15.20.5').EXCHANGE_VERSIONS + print(versions) + ``` - ```text - ['Exchange2019', 'Exchange2016', 'Exchange2013_SP1', 'Exchange2013', 'Exchange2010_SP2', 'Exchange2010_SP1', 'Exchange2010'] - ``` + ```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 = { @@ -54,23 +52,25 @@ class ExchangeVersion(object): } } - EXCHANGE_VERSIONS = ['Exchange2019', 'Exchange2016', 'Exchange2013_SP1', 'Exchange2013', 'Exchange2010_SP2', 'Exchange2010_SP1', 'Exchange2010'] + 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 - + 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])] @@ -78,10 +78,10 @@ def _get_api_version(self, version): @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 ''' diff --git a/pyews/core/oauth2connector.py b/pyews/core/oauth2connector.py new file mode 100644 index 0000000..8370f38 --- /dev/null +++ b/pyews/core/oauth2connector.py @@ -0,0 +1,210 @@ +from oauthlib.oauth2.rfc6749.errors import InvalidGrantError +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 + + +class OAuth2Connector: + """OAuth2Connector 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'): + """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) + + + 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 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 = 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 + + 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=self.redirect_uri, + 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'] 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..a0e3b53 --- /dev/null +++ b/pyews/endpoint/__init__.py @@ -0,0 +1,21 @@ +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 +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/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/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 + ) + ) + ) + ) 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/deletefolder.py b/pyews/endpoint/deletefolder.py new file mode 100644 index 0000000..a2a25a5 --- /dev/null +++ b/pyews/endpoint/deletefolder.py @@ -0,0 +1,24 @@ +from ..service import Operation + + +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" + ) 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/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") + ) + ) 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 + ) 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/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'), + + ) + ) + ) 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..529096a --- /dev/null +++ b/pyews/endpoint/getusersettings.py @@ -0,0 +1,94 @@ +from ..service.autodiscover import Autodiscover, Authentication + + +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 = Authentication.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('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') + ) + ), + ) 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/ews.py b/pyews/ews.py new file mode 100644 index 0000000..fab8697 --- /dev/null +++ b/pyews/ews.py @@ -0,0 +1,220 @@ +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, FindItem, CreateFolder, FindFolder, DeleteFolder + + +class EWS: + + 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): + 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() + + 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_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 = [] + 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: + 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'): + 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) 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() + 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() + + 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: + 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(): + 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) 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..22279bb --- /dev/null +++ b/pyews/service/autodiscover.py @@ -0,0 +1,47 @@ +from .base import Base, ElementMaker, abc, etree, Authentication + + +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 Authentication.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..4db658e --- /dev/null +++ b/pyews/service/base.py @@ -0,0 +1,200 @@ +import abc +import requests +from lxml.builder import ElementMaker +from lxml import etree +from bs4 import BeautifulSoup + +from ..core import Core, Authentication +from ..utils.attributes import RESPONSE_CODES + + +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 _impersonation_header(self): + if hasattr(Authentication, 'impersonate_as') and Authentication.impersonate_as: + return self.T_NAMESPACE.ExchangeImpersonation( + self.T_NAMESPACE.ConnectingSID( + self.T_NAMESPACE.PrimarySmtpAddress(Authentication.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 ') + + 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 __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 + + Returns: + BeautifulSoup: Returns a BeautifulSoup object or None. + """ + for item in Authentication.__dict__.keys(): + if not item.startswith('_'): + if hasattr(Authentication, item): + setattr(self, item, getattr(Authentication, item)) + + for version in Authentication.exchange_versions: + 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 + 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)) + 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=header_dict, + auth=Authentication.credentials, + verify=True + ) + + 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: + self.__logger.warning( + 'The server responded with empty content to POST-request ' + 'from {current}'.format(current=self.__class__.__name__)) + + 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) + 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: + 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: + 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 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..49067c8 --- /dev/null +++ b/pyews/service/operation.py @@ -0,0 +1,27 @@ +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._impersonation_header() + ), + 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 6a755ed..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.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..dd16c04 --- /dev/null +++ b/pyews/utils/attributes.py @@ -0,0 +1,1114 @@ +TRAVERSAL_LIST = [ + 'Deep', + 'Shallow', + 'SoftDeleted', + 'Associated' +] + +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' + ] +} + +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': {} +} + +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", + ] +} + +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 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/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/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) diff --git a/requirements.txt b/requirements.txt index ad7df9f..6616815 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,9 @@ -requests -beautifulsoup4 -lxml -pyyaml +requests==2.25.1 +bs4==0.0.1 +lxml==4.6.3 +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 +oauthlib==3.1.0 +requests-oauthlib==1.3.0 \ No newline at end of file diff --git a/setup.py b/setup.py index a07ceea..df1ef18 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,13 @@ 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='>=3.6, <4', + package_data={ + 'pyews': ['data/logging.yml'] + }, + entry_points={ + 'console_scripts': [ + 'pyews = pyews.__main__:main' + ] + }, ) \ No newline at end of file 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 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..a4f8524 --- /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.ews_url == 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.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' + 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..ae49c17 --- /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.ews_url, 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 deleted file mode 100644 index 7ec34df..0000000 --- a/tests/test_userconfiguration.py +++ /dev/null @@ -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()