diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..70e14a8e --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +ignore = E203, W503 +max-line-length = 120 diff --git a/docs/index.md b/docs/index.md index 62fa7e36..a5ed5339 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,8 +3,7 @@ layout: default title: exchangelib --- -Exchange Web Services client library -==================================== +# Exchange Web Services client library This module is an ORM for your Exchange mailbox, providing Django-style access to all your data. It is a platform-independent, well-performing, well-behaving, well-documented, well-tested and simple interface for @@ -15,21 +14,21 @@ exporting and uploading calendar, mailbox, task, contact and distribution list i Apart from this documentation, we also provide online [source code documentation](https://ecederstrand.github.io/exchangelib/exchangelib/). - ## Table of Contents + * [Installation](#installation) * [Setup and connecting](#setup-and-connecting) - * [Optimizing connections](#optimizing-connections) - * [Fault tolerance](#fault-tolerance) - * [Kerberos and SSPI authentication](#kerberos-and-sspi-authentication) - * [Certificate Based Authentication (CBA)](#certificate-based-authentication-cba) - * [OAuth authentication](#oauth-authentication) - * [Impersonation OAuth on Office 365](#impersonation-oauth-on-office-365) - * [Delegate OAuth on Office 365](#delegate-oauth-on-office-365) - * [MSAL on Office 365](#msal-on-office-365) - * [Caching autodiscover results](#caching-autodiscover-results) - * [Proxies and custom TLS validation](#proxies-and-custom-tls-validation) - * [User-Agent](#user-agent) + * [Optimizing connections](#optimizing-connections) + * [Fault tolerance](#fault-tolerance) + * [Kerberos and SSPI authentication](#kerberos-and-sspi-authentication) + * [Certificate Based Authentication (CBA)](#certificate-based-authentication-cba) + * [OAuth authentication](#oauth-authentication) + * [Impersonation OAuth on Office 365](#impersonation-oauth-on-office-365) + * [Delegate OAuth on Office 365](#delegate-oauth-on-office-365) + * [MSAL on Office 365](#msal-on-office-365) + * [Caching autodiscover results](#caching-autodiscover-results) + * [Proxies and custom TLS validation](#proxies-and-custom-tls-validation) + * [User-Agent](#user-agent) * [Folders](#folders) * [Dates, datetimes and timezones](#dates-datetimes-and-timezones) * [Creating, updating, deleting, sending, moving, archiving, marking as junk](#creating-updating-deleting-sending-moving-archiving-marking-as-junk) @@ -45,14 +44,15 @@ Apart from this documentation, we also provide online * [Out of Facility (OOF)](#out-of-facility-oof) * [Mail tips](#mail-tips) * [Delegate information](#delegate-information) +* [InboxRules](#inboxrules) * [Export and upload](#export-and-upload) * [Synchronization, subscriptions and notifications](#synchronization-subscriptions-and-notifications) * [Non-account services](#non-account-services) * [Troubleshooting](#troubleshooting) * [Tests](#tests) - ## Installation + You can install this package from PyPI: ```bash @@ -84,6 +84,7 @@ This package uses the `lxml` package, and `pykerberos` to support Kerberos authe To be able to install these, you may need to install some additional operating system packages. On Ubuntu: + ```bash apt-get install libxml2-dev libxslt1-dev @@ -92,12 +93,14 @@ apt-get install libkrb5-dev build-essential libssl-dev libffi-dev python-dev ``` On CentOS: + ```bash # For Kerberos support, install these: yum install gcc python-devel krb5-devel krb5-workstation python-devel ``` On FreeBSD, `pip` needs a little help: + ```bash pkg install libxml2 libxslt CFLAGS=-I/usr/local/include pip install lxml @@ -110,13 +113,12 @@ CFLAGS=-I/usr/local/include pip install kerberos pykerberos For other operating systems, please consult the documentation for the Python package that fails to install. - ## Setup and connecting First, specify your credentials. Username is usually in `WINDOMAIN\username` format, where `WINDOMAIN` is the name of the Windows Domain your username is connected to, but some servers also accept usernames in PrimarySMTPAddress -('myusername@example.com') format (Office365 requires it). UPN format is also +(`myusername@example.com`) format (Office365 requires it). UPN format is also supported, if your server expects that. ```python @@ -126,6 +128,7 @@ credentials = Credentials(username="MYWINDOMAIN\\myuser", password="topsecret") # For Office365 credentials = Credentials(username="myuser@example.com", password="topsecret") ``` + If you're running long-running jobs, you may want to enable fault-tolerance. Fault-tolerance means that requests to the server do an exponential backoff and sleep for up to a certain threshold before giving up, if the server is @@ -196,6 +199,7 @@ johns_account = Account( If you want to impersonate an account and access a shared folder that this account has access to, you need to specify the email adress of the shared folder to access the folder: + ```python from exchangelib.folders import Calendar, SingleFolderQuerySet from exchangelib.properties import DistinguishedFolderId, Mailbox @@ -219,7 +223,6 @@ from exchangelib.autodiscover import Autodiscovery Autodiscovery.DNS_RESOLVER_ATTRS["edns"] = False # Disable EDNS queries ``` - ### Optimizing connections According to MSDN docs, you can avoid a per-request AD lookup if you specify @@ -235,6 +238,7 @@ account.identity.upn = "john@subdomain.example.com" If the server doesn't support autodiscover, or you want to avoid the overhead of autodiscover, use a Configuration object to set the hostname instead: + ```python from exchangelib import Configuration, Credentials @@ -280,6 +284,7 @@ config = Configuration(server="mail.example.com", max_connections=10) ``` ### Fault tolerance + By default, we fail on all exceptions from the server. If you want to enable fault tolerance, add a retry policy to your configuration. We will then retry on certain transient errors. By default, we back off exponentially and retry @@ -306,6 +311,7 @@ Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=30) ``` ### Kerberos and SSPI authentication + Kerberos and SSPI authentication are supported via the GSSAPI and SSPI auth types. @@ -317,6 +323,7 @@ config = Configuration(auth_type=SSPI) ``` ### Certificate Based Authentication (CBA) + ```python from exchangelib import Configuration, BaseProtocol, CBA, TLSClientAuth @@ -326,6 +333,7 @@ config = Configuration(auth_type=CBA) ``` ### OAuth authentication + OAuth is supported via the OAUTH2 auth type and the OAuth2Credentials class. Use OAuth2AuthorizationCodeCredentials instead for the authorization code flow (useful for applications that access multiple accounts). @@ -412,15 +420,18 @@ class MyCredentials(OAuth2AuthorizationCodeCredentials): ``` ### Impersonation OAuth on Office 365 + Office 365 is deprecating Basic authentication and switching to MFA for end users and OAuth for everything else. Here's one way to set up an app in Azure that can access accounts in your organization using impersonation - i.e. access to multiple acounts on behalf of those users. First, log into the -[https://admin.microsoft.com](Microsoft 365 Administration) page. Find the link +[Microsoft 365 Administration](https://admin.microsoft.com) page. Find the link to `Azure Active Directory`. Find the link to `Azure Active Directory`. Select `App registrations` in the menu and then `New registration`. Enter an app name and press `Register`: + ![App registration](/exchangelib/assets/img/app_registration.png) + On the next page, note down the Directory (tenant) ID and Application (client) ID, create a secret using the `Add a certificate or secret` link, and note down the Value (client secret) as well. @@ -430,27 +441,32 @@ Continue to the `App registraions` menu item, select your new app, select the `APIs my organization uses` and search for `Office 365 Exchange Online`. Select that API, then `Application permissions` and add the `full_access_as_app` permission: + ![API permissions](/exchangelib/assets/img/api_permissions.png) Finally, continue to the `Enterprise applications` page, select your new app, continue to the `Permissions` page, and check that your app has the `full_access_as_app` permission: + ![API permissions](/exchangelib/assets/img/permissions.png) + If not, press `Grant admin consent for testsuite` and grant access. You should now be able to connect to an account using the `OAuth2Credentials` class as shown above. - ### Delegate OAuth on Office 365 + If you only want to access a single account on Office 365, delegate access is a more suitable access level. Here's one way to set up an app in Azure that can access accounts in your organization using delegation - i.e. access to the same account that you are logging in as. First, log into the -[https://admin.microsoft.com](Microsoft 365 Administration) page. Find the link +[Microsoft 365 Administration](https://admin.microsoft.com) page. Find the link to `Azure Active Directory`. Select `App registrations` in the menu and then `New registration`. Enter an app name and press `Register`: + ![App registration](/exchangelib/assets/img/delegate_app_registration.png) + On the next page, note down the Directory (tenant) ID and Application (client) ID, create a secret using the `Add a certificate or secret` link, and note down the Value (client secret) as well. @@ -460,19 +476,22 @@ Continue to the `App registraions` menu item, select your new app, select the `APIs my organization uses` and search for `Office 365 Exchange Online`. Select that API and then `Delegated permissions` and add the `EWS.AccessAsUser.All` permission under the `EWS` section: + ![API permissions](/exchangelib/assets/img/delegate_app_api_permissions.png) Finally, continue to the `Enterprise applications` page, select your new app, continue to the `Permissions` page, and check that your app has the `EWS.AccessAsUser.All` permission: + ![API permissions](/exchangelib/assets/img/delegate_app_permissions.png) + If not, press `Grant admin consent for testsuite_delegate` and grant access. You should now be able to connect to an account using the `OAuth2LegacyCredentials` class as shown above. - ### MSAL on Office 365 + The [Microsoft Authentication Library](https://github.com/AzureAD/microsoft-authentication-library-for-python) supports obtaining OAuth tokens via a range of different methods. You can use MSAL to fetch a token valid for EWS and then only provide the token to this @@ -526,8 +545,8 @@ a = Account( print(a.root.tree()) ``` - ### Caching autodiscover results + If you're connecting to the same account very often, you can cache the autodiscover result for later so you can skip the autodiscover lookup: @@ -567,6 +586,7 @@ shared between processes and is not deleted when your program exits. A cache entry for a domain is removed automatically if autodiscovery fails for an email in that domain. It's possible to clear the entire cache completely if you want: + ```python from exchangelib.autodiscover import clear_cache @@ -649,6 +669,7 @@ BaseProtocol.USERAGENT = "Auto-Reply/0.1.0" ``` ## Folders + All wellknown folders are available as properties on the account, e.g. as `account.root`, `account.calendar`, `account.trash`, `account.inbox`, `account.outbox`, `account.sent`, `account.junk`, `account.tasks` and `account.contacts`. @@ -698,6 +719,7 @@ some_folder.absolute # Returns the full path as a string ``` tree() returns a string representation of the tree structure at a given level + ```python print(a.root.tree()) """ @@ -951,6 +973,7 @@ forward_draft.send() EWS distinguishes between plain text and HTML body contents. If you want to send HTML body content, use the HTMLBody helper. Clients will see this as HTML and display the body correctly: + ```python from exchangelib import HTMLBody @@ -1111,6 +1134,7 @@ folder_is_empty = not a.inbox.all().exists() # Efficient tasting ``` Restricting returned attributes: + ```python sparse_items = a.inbox.all().only("subject", "start") # Dig deeper on indexed properties @@ -1120,6 +1144,7 @@ sparse_items = a.contacts.all().only("physical_addresses__Home__street") ``` Return values as dicts, nested or flat lists instead of objects: + ```python ids_as_dict = a.inbox.all().values("id", "changekey") values_as_list = a.inbox.all().values_list("subject", "body") @@ -1217,7 +1242,7 @@ validating the syntax of the QueryString - we just pass the string verbatim to EWS. Read more about the QueryString syntax here: -https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/querystring-querystringtype + ```python a.inbox.filter("subject:XXX") @@ -1504,7 +1529,6 @@ att = FileAttachment( contact.attach(att) ``` - ## Extended properties Extended properties makes it possible to attach custom key-value pairs @@ -1582,6 +1606,7 @@ others don't. Custom fields must be registered on the generic Folder or RootOfHierarchy folder classes. Here's an example of getting the size (in bytes) of a folder: + ```python from exchangelib import ExtendedProperty, Folder @@ -1596,18 +1621,18 @@ print(a.inbox.size) ``` In general, here's how to work with any MAPI property as listed in e.g. -https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/mapi-properties. +. Let's take `PidLidTaskDueDate` as an example. This is the due date for a message maked with the follow-up flag in Microsoft Outlook. `PidLidTaskDueDate` is documented at -https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/pidlidtaskduedate-canonical-property. +. The property ID is `0x00008105` and the property set is `PSETID_Task`. But EWS wants the UUID for `PSETID_Task`, so we look that up in the MS-OXPROPS pdf: -https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxprops/f6ab1613-aefe-447d-a49c-18217230b148 + The UUID is `00062003-0000-0000-C000-000000000046`. The property type is `PT_SYSTIME` which is also called `SystemTime` (see -https://docs.microsoft.com/en-us/dotnet/api/microsoft.exchange.webservices.data.mapipropertytype ) + ) In conclusion, the definition for the due date becomes: @@ -1632,7 +1657,6 @@ FileAttachments have a 'content' attribute containing the binary content of the file, and ItemAttachments have an 'item' attribute containing the item. The item can be a Message, CalendarItem, Task etc. - ```python import os.path from exchangelib import Account, FileAttachment, ItemAttachment, Message @@ -1694,6 +1718,7 @@ item.detach(my_file) If you want to embed an image in the item body, you can link to the file in the HTML. + ```python from exchangelib import HTMLBody @@ -1810,10 +1835,10 @@ master.save(update_fields=["subject"]) Each `Message` item has four timestamp fields: -- `datetime_created` -- `datetime_sent` -- `datetime_received` -- `last_modified_time` +* `datetime_created` +* `datetime_sent` +* `datetime_received` +* `last_modified_time` The values for these fields are set by the Exchange server and are not modifiable via EWS. All values are timezone-aware `EWSDateTime` @@ -1851,8 +1876,8 @@ a.oof_settings = OofSettings( ) ``` - ## Mail tips + Mail tips for an account contain some extra information about the account, e.g. OOF information, max message size, whether the mailbox is full, messages are moderated etc. Here's how to get mail tips for a single account: @@ -1864,11 +1889,12 @@ a = Account(...) print(a.mail_tips) ``` - ## Delegate information + An account can have delegates, which are other users that are allowed to access the account. Here's how to fetch information about those delegates, including which level of access they have to the account. + ```python from exchangelib import Account @@ -1876,6 +1902,69 @@ a = Account(...) print(a.delegates) ``` +## Inbox Rules + +An account can have several inbox rules, which are used to trigger the rule actions for a rule based on the corresponding +conditions in the mailbox. Here's how to fetch information about those rules: + +```python +from exchangelib import Account + +a = Account(...) +print(a.rules) +``` + +The `InboxRules` element represents an array of rules in the user's mailbox. Each `Rule` is structured as follows: + +* `id`: Specifies the rule identifier. +* `display_name`: Contains the display name of a rule. +* `priority`: Indicates the order in which a rule is to be run. +* `is_enabled`: Indicates whether the rule is enabled. +* `is_not_supported`: Indicates whether the rule cannot be modified with the managed code APIs. +* `is_in_error`: Indicates whether the rule is in an error condition. +* `conditions`: Identifies the conditions that, when fulfilled, will trigger the rule actions for a rule. +* `exceptions`: Identifies the exceptions that represent all the available rule exception conditions for the inbox rule. +* `actions`: Represents the actions to be taken on a message when the conditions are fulfilled. + +Here are examples of operations for adding, deleting, modifying, and querying InboxRules. + +```python +from exchangelib import Account +from exchangelib.properties import Actions, Conditions, Exceptions, Rule + +a = Account(...) + +print("Rules before creation:", a.rules, "\n") + +# Create Rule instance +rule = Rule( + display_name="test_exchangelib_rule", + priority=1, + is_enabled=True, + conditions=Conditions(contains_sender_strings=["sender_example"]), + exceptions=Exceptions(), + actions=Actions(delete=True), +) + +# Create rule +a.create_rule(rule) +print("Rule:", rule) +print("Created rule with ID:", rule.id, "\n") + +# Get rule list +print("Rules after creation:", a.rules, "\n") + +# Modify rule +print("Modifying rule with ID:", rule.id) +rule.display_name = "test_exchangelib_rule(modified)" +a.set_rule(rule) +print("Rules after modification:", a.rules, "\n") + +# Delete rule +print("Deleting rule with ID:", rule.id) +a.delete_rule(rule=rule) +print("Rules after deletion:", a.rules) +``` ## Export and upload @@ -1902,6 +1991,7 @@ using EWS is available at [https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/notification-subscriptions-mailbox-events-and-ews-in-exchange](https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/notification-subscriptions-mailbox-events-and-ews-in-exchange): The following shows how to synchronize folders and items: + ```python from exchangelib import Account @@ -1933,6 +2023,7 @@ following, we show only examples for subscriptions on `Account.inbox`, but the o also possible. Here's how to create a pull subscription that can be used to pull events from the server: + ```python # Subscribe to all folders subscription_id, watermark = a.subscribe_to_pull() @@ -1948,6 +2039,7 @@ Here's how to create a push subscription. The server will regularly send an HTTP request to the callback URL to deliver changes or a status message. There is also support for parsing the POST data that the Exchange server sends to the callback URL, and for creating proper responses to these URLs. + ```python subscription_id, watermark = a.inbox.subscribe_to_push( callback_url="https://my_app.example.com/callback_url" @@ -1958,6 +2050,7 @@ When the server sends a push notification, the POST data contains a `SendNotification` XML document. You can use this package in the callback URL implementation to parse this data. Here's a short example of a Flask app that handles these documents: + ```python from exchangelib.services import SendNotification from flask import Flask, request @@ -1980,6 +2073,7 @@ def notify_me(): Here's how to create a streaming subscription that can be used to stream events from the server. + ```python subscription_id = a.inbox.subscribe_to_streaming() ``` @@ -1987,12 +2081,14 @@ subscription_id = a.inbox.subscribe_to_streaming() Cancel the subscription when you're done synchronizing. This is not supported for push subscriptions. They cancel automatically after a certain amount of failed attempts, or you can let your callback URL send an unsubscribe response as described above. + ```python a.inbox.unsubscribe(subscription_id) ``` When creating subscriptions, you can also use one of the three context managers that handle unsubscription automatically: + ```python with a.inbox.pull_subscription() as (subscription_id, watermark): pass @@ -2153,7 +2249,6 @@ for busy_info in a.protocol.get_free_busy_info(accounts=accounts, start=start, e print(account_tz.localize(event.start), account_tz.localize(event.end)) ``` - ## Troubleshooting If you are having trouble using this library, the first thing to try is @@ -2182,7 +2277,6 @@ from exchangelib import CalendarItem print(CalendarItem.__doc__) ``` - ## Tests The test suite is split into unit tests, and integration tests that require a real Exchange diff --git a/exchangelib/account.py b/exchangelib/account.py index b26feb35..966db5cb 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -6,7 +6,7 @@ from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION -from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone +from .errors import ErrorItemNotFound, InvalidEnumValue, InvalidTypeError, ResponseMessageError, UnknownTimeZone from .ewsdatetime import UTC, EWSTimeZone from .fields import FieldPath, TextField from .folders import ( @@ -56,16 +56,19 @@ ) from .folders.collections import PullSubscription, PushSubscription, StreamingSubscription from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE -from .properties import EWSElement, Mailbox, SendingAs +from .properties import EWSElement, Mailbox, Rule, SendingAs from .protocol import Protocol from .queryset import QuerySet from .services import ( ArchiveItem, CopyItem, + CreateInboxRule, CreateItem, + DeleteInboxRule, DeleteItem, ExportItems, GetDelegate, + GetInboxRules, GetItem, GetMailTips, GetPersona, @@ -73,6 +76,7 @@ MarkAsJunk, MoveItem, SendItem, + SetInboxRule, SetUserOofSettings, SubscribeToPull, SubscribeToPush, @@ -747,6 +751,49 @@ def delegates(self): """Return a list of DelegateUser objects representing the delegates that are set on this account.""" return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True)) + @property + def rules(self): + """Return a list of Rule objects representing the rules that are set on this account.""" + return list(GetInboxRules(account=self).call()) + + def create_rule(self, rule: Rule): + """Create an Inbox rule. + + :param rule: The rule to create. Must have at least 'display_name' set. + :return: None if success, else raises an error. + """ + CreateInboxRule(account=self).get(rule=rule, remove_outlook_rule_blob=True) + # After creating the rule, query all rules, + # find the rule that was just created, and return its ID. + try: + rule.id = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name].id + except KeyError: + raise ResponseMessageError(f"Failed to create rule ({rule.display_name})!") + + def set_rule(self, rule: Rule): + """Modify an Inbox rule. + + :param rule: The rule to set. Must have an ID. + :return: None if success, else raises an error. + """ + SetInboxRule(account=self).get(rule=rule) + + def delete_rule(self, rule: Rule): + """Delete an Inbox rule. + + :param rule: The rule to delete. Must have ID or 'display_name'. + :return: None if success, else raises an error. + """ + if not rule.id: + if not rule.display_name: + raise ValueError("Rule must have ID or display_name") + try: + rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name] + except KeyError: + raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}") + DeleteInboxRule(account=self).get(rule=rule) + rule.id = None + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): """Create a pull subscription. diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 8b65f3e4..c2154b4e 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -4,6 +4,7 @@ for ad-hoc access e.g. granted manually by the user. See https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/impersonation-and-ews-in-exchange """ + import abc import logging from threading import RLock diff --git a/exchangelib/errors.py b/exchangelib/errors.py index 41937ddc..0a6bac51 100644 --- a/exchangelib/errors.py +++ b/exchangelib/errors.py @@ -1,4 +1,5 @@ """Stores errors specific to this package, and mirrors all the possible errors that EWS can return.""" + from urllib.parse import urlparse diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 08562e29..76f51b67 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1723,3 +1723,65 @@ def from_xml(self, elem, account): continue events.append(value_cls.from_xml(elem=event, account=account)) return events or self.default + + +FLAG_ACTION_CHOICES = [ + Choice("Any"), + Choice("Call"), + Choice("DoNotForward"), + Choice("FollowUp"), + Choice("FYI"), + Choice("Forward"), + Choice("NoResponseNecessary"), + Choice("Read"), + Choice("Reply"), + Choice("ReplyToAll"), + Choice("Review"), +] + + +class FlaggedForActionField(ChoiceField): + """ + A field specifies the flag for action value that + must appear on incoming messages in order for the condition + or exception to apply. + """ + + def __init__(self, *args, **kwargs): + kwargs["choices"] = FLAG_ACTION_CHOICES + super().__init__(*args, **kwargs) + + +IMPORTANCE_CHOICES = [ + Choice("Low"), + Choice("Normal"), + Choice("High"), +] + + +class ImportanceField(ChoiceField): + """ + A field that describes the importance of an item or + the aggregated importance of all items in a conversation + in the current folder. + """ + + def __init__(self, *args, **kwargs): + kwargs["choices"] = IMPORTANCE_CHOICES + super().__init__(*args, **kwargs) + + +SENSITIVITY_CHOICES = [ + Choice("Normal"), + Choice("Personal"), + Choice("Private"), + Choice("Confidential"), +] + + +class SensitivityField(ChoiceField): + """A field that indicates the sensitivity level of an item.""" + + def __init__(self, *args, **kwargs): + kwargs["choices"] = SENSITIVITY_CHOICES + super().__init__(*args, **kwargs) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 814ff741..bcfd3081 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -36,10 +36,12 @@ ExtendedPropertyField, Field, FieldPath, + FlaggedForActionField, FreeBusyStatusField, GenericEventListField, IdElementField, IdField, + ImportanceField, IntegerField, InvalidField, InvalidFieldForVersion, @@ -48,6 +50,7 @@ RecipientAddressField, ReferenceItemIdField, RoutingTypeField, + SensitivityField, SubField, TextField, TimeDeltaField, @@ -2140,3 +2143,179 @@ def from_xml(cls, elem, account): user_settings_errors=user_settings_errors, user_settings=user_settings, ) + + +class WithinDateRange(EWSElement): + """MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/withindaterange + """ + + ELEMENT_NAME = "DateRange" + NAMESPACE = MNS + + start_date_time = DateTimeField(field_uri="StartDateTime", is_required=True) + end_date_time = DateTimeField(field_uri="EndDateTime", is_required=True) + + +class WithinSizeRange(EWSElement): + """MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/withinsizerange + """ + + ELEMENT_NAME = "SizeRange" + NAMESPACE = MNS + + minimum_size = IntegerField(field_uri="MinimumSize", is_required=True) + maximum_size = IntegerField(field_uri="MaximumSize", is_required=True) + + +class Conditions(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conditions""" + + ELEMENT_NAME = "Conditions" + NAMESPACE = TNS + + categories = CharListField(field_uri="Categories") + contains_body_strings = CharListField(field_uri="ContainsBodyStrings") + contains_header_strings = CharListField(field_uri="ContainsHeaderStrings") + contains_recipient_strings = CharListField(field_uri="ContainsRecipientStrings") + contains_sender_strings = CharListField(field_uri="ContainsSenderStrings") + contains_subject_or_body_strings = CharListField(field_uri="ContainsSubjectOrBodyStrings") + contains_subject_strings = CharListField(field_uri="ContainsSubjectStrings") + flagged_for_action = FlaggedForActionField(field_uri="FlaggedForAction") + from_addresses = EWSElementField(value_cls=Mailbox, field_uri="FromAddresses") + from_connected_accounts = CharListField(field_uri="FromConnectedAccounts") + has_attachments = BooleanField(field_uri="HasAttachments") + importance = ImportanceField(field_uri="Importance") + is_approval_request = BooleanField(field_uri="IsApprovalRequest") + is_automatic_forward = BooleanField(field_uri="IsAutomaticForward") + is_automatic_reply = BooleanField(field_uri="IsAutomaticReply") + is_encrypted = BooleanField(field_uri="IsEncrypted") + is_meeting_request = BooleanField(field_uri="IsMeetingRequest") + is_meeting_response = BooleanField(field_uri="IsMeetingResponse") + is_ndr = BooleanField(field_uri="IsNDR") + is_permission_controlled = BooleanField(field_uri="IsPermissionControlled") + is_read_receipt = BooleanField(field_uri="IsReadReceipt") + is_signed = BooleanField(field_uri="IsSigned") + is_voicemail = BooleanField(field_uri="IsVoicemail") + item_classes = CharListField(field_uri="ItemClasses") + message_classifications = CharListField(field_uri="MessageClassifications") + not_sent_to_me = BooleanField(field_uri="NotSentToMe") + sent_cc_me = BooleanField(field_uri="SentCcMe") + sent_only_to_me = BooleanField(field_uri="SentOnlyToMe") + sent_to_addresses = EWSElementField(value_cls=Mailbox, field_uri="SentToAddresses") + sent_to_me = BooleanField(field_uri="SentToMe") + sent_to_or_cc_me = BooleanField(field_uri="SentToOrCcMe") + sensitivity = SensitivityField(field_uri="Sensitivity") + within_date_range = EWSElementField(value_cls=WithinDateRange, field_uri="WithinDateRange") + within_size_range = EWSElementField(value_cls=WithinSizeRange, field_uri="WithinSizeRange") + + +class Exceptions(Conditions): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exceptions""" + + ELEMENT_NAME = "Exceptions" + NAMESPACE = TNS + + +class CopyToFolder(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copytofolder""" + + ELEMENT_NAME = "CopyToFolder" + NAMESPACE = MNS + + folder_id = EWSElementField(value_cls=FolderId, field_uri="FolderId") + distinguished_folder_id = EWSElementField(value_cls=DistinguishedFolderId, field_uri="DistinguishedFolderId") + + +class MoveToFolder(CopyToFolder): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movetofolder""" + + ELEMENT_NAME = "MoveToFolder" + + +class Actions(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/actions""" + + ELEMENT_NAME = "Actions" + NAMESPACE = TNS + + assign_categories = CharListField(field_uri="AssignCategories") + copy_to_folder = EWSElementField(value_cls=CopyToFolder, field_uri="CopyToFolder") + delete = BooleanField(field_uri="Delete") + forward_as_attachment_to_recipients = EWSElementField( + value_cls=Mailbox, field_uri="ForwardAsAttachmentToRecipients" + ) + forward_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="ForwardToRecipients") + mark_importance = ImportanceField(field_uri="MarkImportance") + mark_as_read = BooleanField(field_uri="MarkAsRead") + move_to_folder = EWSElementField(value_cls=MoveToFolder, field_uri="MoveToFolder") + permanent_delete = BooleanField(field_uri="PermanentDelete") + redirect_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="RedirectToRecipients") + send_sms_alert_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="SendSMSAlertToRecipients") + server_reply_with_message = EWSElementField(value_cls=ItemId, field_uri="ServerReplyWithMessage") + stop_processing_rules = BooleanField(field_uri="StopProcessingRules") + + +class Rule(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rule-ruletype""" + + ELEMENT_NAME = "Rule" + NAMESPACE = TNS + + id = CharField(field_uri="RuleId") + display_name = CharField(field_uri="DisplayName") + priority = IntegerField(field_uri="Priority") + is_enabled = BooleanField(field_uri="IsEnabled") + is_not_supported = BooleanField(field_uri="IsNotSupported") + is_in_error = BooleanField(field_uri="IsInError") + conditions = EWSElementField(value_cls=Conditions) + exceptions = EWSElementField(value_cls=Exceptions) + actions = EWSElementField(value_cls=Actions) + + +class InboxRules(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/inboxrules""" + + ELEMENT_NAME = "InboxRules" + NAMESPACE = MNS + + rule = EWSElementListField(value_cls=Rule) + + +class CreateRuleOperation(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createruleoperation""" + + ELEMENT_NAME = "CreateRuleOperation" + NAMESPACE = TNS + + rule = EWSElementField(value_cls=Rule) + + +class SetRuleOperation(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setruleoperation""" + + ELEMENT_NAME = "SetRuleOperation" + NAMESPACE = TNS + + rule = EWSElementField(value_cls=Rule) + + +class DeleteRuleOperation(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteruleoperation""" + + ELEMENT_NAME = "DeleteRuleOperation" + NAMESPACE = TNS + + id = CharField(field_uri="RuleId") + + +class Operations(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/operations""" + + ELEMENT_NAME = "Operations" + NAMESPACE = MNS + + create_rule_operation = EWSElementField(value_cls=CreateRuleOperation) + set_rule_operation = EWSElementField(value_cls=SetRuleOperation) + delete_rule_operation = EWSElementField(value_cls=DeleteRuleOperation) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 339c8f85..2b8dc52b 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -4,6 +4,7 @@ Protocols should be accessed through an Account, and are either created from a default Configuration or autodiscovered when creating an Account. """ + import abc import datetime import logging diff --git a/exchangelib/services/__init__.py b/exchangelib/services/__init__.py index ef3a98cd..77a22f21 100644 --- a/exchangelib/services/__init__.py +++ b/exchangelib/services/__init__.py @@ -41,6 +41,7 @@ from .get_user_configuration import GetUserConfiguration from .get_user_oof_settings import GetUserOofSettings from .get_user_settings import GetUserSettings +from .inbox_rules import CreateInboxRule, DeleteInboxRule, GetInboxRules, SetInboxRule from .mark_as_junk import MarkAsJunk from .move_folder import MoveFolder from .move_item import MoveItem @@ -109,4 +110,8 @@ "UpdateItem", "UpdateUserConfiguration", "UploadItems", + "GetInboxRules", + "CreateInboxRule", + "SetInboxRule", + "DeleteInboxRule", ] diff --git a/exchangelib/services/inbox_rules.py b/exchangelib/services/inbox_rules.py new file mode 100644 index 00000000..3545d7c1 --- /dev/null +++ b/exchangelib/services/inbox_rules.py @@ -0,0 +1,121 @@ +from typing import Any, Generator, Optional, Union + +from ..errors import ErrorInvalidOperation +from ..properties import CreateRuleOperation, DeleteRuleOperation, InboxRules, Operations, Rule, SetRuleOperation +from ..util import MNS, add_xml_child, create_element, get_xml_attr, set_xml_value +from ..version import EXCHANGE_2010 +from .common import EWSAccountService + + +class GetInboxRules(EWSAccountService): + """ + MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getinboxrules-operation + + The GetInboxRules operation uses Exchange Web Services to retrieve Inbox rules in the identified user's mailbox. + """ + + SERVICE_NAME = "GetInboxRules" + supported_from = EXCHANGE_2010 + element_container_name = InboxRules.response_tag() + ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) + + def call(self, mailbox: Optional[str] = None) -> Generator[Union[Rule, Exception, None], Any, None]: + if not mailbox: + mailbox = self.account.primary_smtp_address + payload = self.get_payload(mailbox=mailbox) + elements = self._get_elements(payload=payload) + return self._elems_to_objs(elements) + + def _elem_to_obj(self, elem): + return Rule.from_xml(elem=elem, account=self.account) + + def get_payload(self, mailbox): + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:MailboxSmtpAddress", mailbox) + return payload + + def _get_element_container(self, message, name=None): + if name: + response_class = message.get("ResponseClass") + response_code = get_xml_attr(message, f"{{{MNS}}}ResponseCode") + if response_class == "Success" and response_code == "NoError" and message.find(name) is None: + return [] + return super()._get_element_container(message, name) + + +class UpdateInboxRules(EWSAccountService): + """ + MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation + + The UpdateInboxRules operation updates the authenticated user's Inbox rules by applying the specified operations. + UpdateInboxRules is used to create an Inbox rule, to set an Inbox rule, or to delete an Inbox rule. + + When you use the UpdateInboxRules operation, Exchange Web Services deletes client-side send rules. + Client-side send rules are stored on the client in the rule Folder Associated Information (FAI) Message and nowhere + else. EWS deletes this rule FAI message by default, based on the expectation that Outlook will recreate it. + However, Outlook can't recreate rules that don't also exist as an extended rule, and client-side send rules don't + exist as extended rules. As a result, these rules are lost. We suggest you consider this when designing your + solution. + """ + + SERVICE_NAME = "UpdateInboxRules" + supported_from = EXCHANGE_2010 + ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) + + +class CreateInboxRule(UpdateInboxRules): + """ + MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example + """ + + def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): + payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) + return self._get_elements(payload=payload) + + def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) + operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule)) + set_xml_value(payload, operations, version=self.account.version) + return payload + + +class SetInboxRule(UpdateInboxRules): + """ + MSDN: + https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-set-rule-request-example + """ + + def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): + payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) + return self._get_elements(payload=payload) + + def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): + if not rule.id: + raise ValueError("Rule must have an ID") + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) + operations = Operations(set_rule_operation=SetRuleOperation(rule=rule)) + set_xml_value(payload, operations, version=self.account.version) + return payload + + +class DeleteInboxRule(UpdateInboxRules): + """ + MSDN: + https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-delete-rule-request-example + """ + + def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): + payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) + return self._get_elements(payload=payload) + + def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): + if not rule.id: + raise ValueError("Rule must have an ID") + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) + operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id)) + set_xml_value(payload, operations, version=self.account.version) + return payload diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index 7ca6883b..c87c9b40 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -1,6 +1,7 @@ """The 'Subscribe' service has three different modes - pull, push and streaming - with different signatures. Implement as three distinct classes. """ + import abc from ..util import MNS, create_element diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 7d82762d..56936a58 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -1,5 +1,6 @@ """A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL """ + import re import requests diff --git a/scripts/notifier.py b/scripts/notifier.py index 6725e576..f986cf7b 100644 --- a/scripts/notifier.py +++ b/scripts/notifier.py @@ -37,6 +37,7 @@ done """ + import sys import warnings from datetime import datetime, timedelta diff --git a/tests/test_account.py b/tests/test_account.py index 8a3bc401..090c1fc9 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -19,11 +19,15 @@ from exchangelib.folders import Calendar from exchangelib.items import Message from exchangelib.properties import ( + Actions, + Conditions, DelegatePermissions, DelegateUser, + Exceptions, MailTips, OutOfOffice, RecipientAddress, + Rule, SendingAs, UserId, ) @@ -31,7 +35,7 @@ from exchangelib.services import GetDelegate, GetMailTips from exchangelib.version import EXCHANGE_2007_SP1, Version -from .common import EWSTest +from .common import EWSTest, get_random_string class AccountTest(EWSTest): @@ -337,3 +341,36 @@ def test_protocol_default_values(self): ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) + + def test_inbox_rules(self): + # Clean up first + for rule in self.account.rules: + self.account.delete_rule(rule) + + self.assertEqual(len(self.account.rules), 0) + + # Create rule + display_name = get_random_string(16) + rule = Rule( + display_name=display_name, + priority=1, + is_enabled=True, + conditions=Conditions(contains_sender_strings=[get_random_string(8)]), + exceptions=Exceptions(), + actions=Actions(delete=True), + ) + self.assertIsNone(rule.id) + self.account.create_rule(rule=rule) + self.assertIsNotNone(rule.id) + self.assertEqual(len(self.account.rules), 1) + self.assertEqual(self.account.rules[0].display_name, display_name) + + # Update rule + rule.display_name = get_random_string(16) + self.account.set_rule(rule=rule) + self.assertEqual(len(self.account.rules), 1) + self.assertNotEqual(self.account.rules[0].display_name, display_name) + + # Delete rule + self.account.delete_rule(rule=rule) + self.assertEqual(len(self.account.rules), 0)